diff --git a/.gitignore b/.gitignore index bfcbc9104..f1e01ea63 100644 --- a/.gitignore +++ b/.gitignore @@ -65,12 +65,12 @@ app/android/note_room_key_store.jks ### iOS # Generated files -/iosApp/iosApp.xcworkspace/xcuserdata/ -/iosApp/fastlane/report.xml +/app/iosApp/iosApp.xcworkspace/xcuserdata/ +/app/iosApp/fastlane/report.xml # Built application files -/iosApp/iosApp.app.dSYM.zip -/iosApp/iosApp.ipa +/app/iosApp/iosApp.app.dSYM.zip +/app/iosApp/iosApp.ipa # Code signing files /app/iosApp/fastlane/28F5CB4337.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cf84a1b9d..c9b1292f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,7 @@ NoteDelight is a **Kotlin Multiplatform** note-taking application with database ### Supported Platforms -- ✅ Android (minSdk 24) +- ✅ Android (minSdk 23) - ✅ iOS (14.0+) - ✅ Desktop (Windows, macOS, Linux) - ✅ Web (WebAssembly, experimental) @@ -89,6 +89,60 @@ kotlin.code.style=official - One blank line between functions - Two blank lines between top-level declarations - No blank lines at start/end of blocks +- Inside an `expect`/`interface` body with several method signatures, separate them with blank lines so the + declaration list reads as members rather than a wall of text: + ```kotlin + expect class BiometricInteractor { + + suspend fun canAuthenticate(): Boolean + + fun hasStoredPassword(): Boolean + // ... + } + ``` + +#### Call Sites & Lambdas +- **Use named arguments** when calling a function with three or more parameters, or whenever the call site + would otherwise need a same-typed positional argument list. This is especially important for + cross-platform interactors and view-model actions: + ```kotlin + biometricInteractor.encryptAndStorePassword( + password = password, + title = title, + subtitle = subtitle, + negativeButton = negativeButton, + ) + ``` +- **Annotate non-trivial local types** so the reader does not have to follow inference through several + generics or platform calls (`val res: DecryptedPasswordResult = ...`, `val plain: ByteArray = ...`). +- **Order `when` branches by the success path first**, error/`else` branches afterwards — this matches the + way ViewModels read top-to-bottom in the project. Prefer `when (result) { is Success -> ...; else -> ... }` + over an inverted `if (!success) ... else ...` ladder. +- **Collapse trivial `viewModelScope.launch` blocks to a single line** when their body is one statement + (e.g. `private fun cancel() = viewModelScope.launch { router.popBackStack() }`). +- **Compose state edits**: prefer a single `mutableStateFlow.update { it.copy(...) }` that sets every field + affected by an event over multiple chained `update` calls. It keeps the resulting state atomic and + makes the visible transition obvious. + +#### Composables, strings, and event arguments +- **Do not pipe localized strings through actions or screen wrappers as empty placeholders.** If a screen + needs a `stringResource` to dispatch an action, read it directly at the call site: + ```kotlin + // ❌ Avoid + onClick = { onAction(SignInAction.OnBiometricClick("", "", "")) } + // ...wrapper that overwrites the empty strings before forwarding to the ViewModel. + + // ✅ Prefer + val title = stringResource(Res.string.biometric_prompt_title) + val subtitle = stringResource(Res.string.biometric_prompt_subtitle) + val negative = stringResource(Res.string.biometric_prompt_negative_button) + onClick = { onAction(SignInAction.OnBiometricClick(title, subtitle, negative)) } + ``` + When the strings are needed inside a stateless `…Body` composable that also has its own preview, expose + them as defaulted parameters (`title: String = stringResource(Res.string.…)`) instead of forwarding the + action with empty strings and then re-resolving them in the stateful wrapper. +- **Prefer method references for forwarding callbacks** (`onAction = signInViewModel::onAction`) when the + wrapper performs no transformation. ### Code Organization diff --git a/README.md b/README.md index b8beadbb7..233cddab5 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Supported platforms: | database | ✅ | ✅ | ✅ | ✅ | | encryption | ✅ | ✅ | ✅ | ✅ | | backup | ✅ | ✅ | ✅ | ✅ | +| biometric | ✅ | ✅ | | | Interested in contributing new features or fixes? Check out [CONTRIBUTING.md](/CONTRIBUTING.md). 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 000000000..ebaee9c62 --- /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 000000000..6342cb909 --- /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/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml index ee9f8281e..eee8352ab 100644 --- a/app/android/src/main/AndroidManifest.xml +++ b/app/android/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + - - - - diff --git a/app/iosApp/iosApp/Info.plist b/app/iosApp/iosApp/Info.plist index 01aa30764..0a1f88fcc 100644 --- a/app/iosApp/iosApp/Info.plist +++ b/app/iosApp/iosApp/Info.plist @@ -4,6 +4,8 @@ ITSAppUsesNonExemptEncryption + NSFaceIDUsageDescription + Used to unlock your encrypted notes. CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable diff --git a/core/presentation/README.md b/core/presentation/README.md index 99e751759..1291b7ade 100644 --- a/core/presentation/README.md +++ b/core/presentation/README.md @@ -120,6 +120,8 @@ All ViewModels are **100% shared** across platforms with no platform-specific co ### Core Dependencies - `core:domain` - Domain models and use cases +- `feature:biometric:domain` - BiometricInteractor (used by SignInViewModel and SettingsViewModel) +- `feature:backup:domain` - Backup use cases - `androidx-lifecycle-viewmodel` - ViewModel base class (multiplatform) - `kotlinx-serialization-json` - Serialization support - `kotlinx-coroutines` - Asynchronous programming diff --git a/core/presentation/build.gradle.kts b/core/presentation/build.gradle.kts index 4fcf91320..83f23d5f0 100644 --- a/core/presentation/build.gradle.kts +++ b/core/presentation/build.gradle.kts @@ -33,6 +33,7 @@ kotlin { commonMain.dependencies { implementation(projects.core.domain) implementation(projects.feature.backup.domain) + implementation(projects.feature.biometric.domain) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.kotlinx.serialization.json) implementation(libs.kermit) 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 95106c89a..c69d740c1 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 @@ -8,6 +8,7 @@ import com.softartdev.notedelight.CoroutineDispatchersStub import com.softartdev.notedelight.PrintLogWriter import com.softartdev.notedelight.db.NoteDAO import com.softartdev.notedelight.interactor.AdaptiveInteractor +import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.LocaleInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.model.SettingsCategory @@ -26,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 @@ -66,6 +67,7 @@ class AdaptiveInteractorTest { private val mockDeleteNoteUseCase = Mockito.mock(DeleteNoteUseCase::class.java) private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java) private val mockLocaleInteractor = Mockito.mock(LocaleInteractor::class.java) + private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java) private val mockAppVersionUseCase = Mockito.mock(AppVersionUseCase::class.java) private val checkSqlCipherVersionUseCase = CheckSqlCipherVersionUseCase(mockSafeRepo) private val revealFileListUseCase = RevealFileListUseCase() @@ -118,6 +120,7 @@ class AdaptiveInteractorTest { revealFileListUseCase = revealFileListUseCase, localeInteractor = mockLocaleInteractor, adaptiveInteractor = adaptiveInteractor, + biometricInteractor = mockBiometricInteractor, coroutineDispatchers = coroutineDispatchers, ) Mockito.`when`(mockNoteDAO.pagingDataFlow).thenReturn(flowOf(PagingData.empty())) 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..7b7d33108 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 @@ -4,6 +4,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import app.cash.turbine.test import com.softartdev.notedelight.CoroutineDispatchersStub import com.softartdev.notedelight.interactor.AdaptiveInteractor +import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.LocaleInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarMessage @@ -21,8 +22,10 @@ import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase import com.softartdev.notedelight.usecase.settings.ImportDatabaseUseCase import com.softartdev.notedelight.usecase.settings.RevealFileListUseCase import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.Mockito @@ -44,6 +47,7 @@ class SettingsViewModelTest { private val mockRouter = Mockito.mock(Router::class.java) private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java) private val mockLocaleInteractor = Mockito.mock(LocaleInteractor::class.java) + private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java) private val mockAppVersionUseCase = Mockito.mock(AppVersionUseCase::class.java) private val adaptiveInteractor = AdaptiveInteractor() private val coroutineDispatchers = CoroutineDispatchersStub(mainDispatcherRule.testDispatcher.scheduler) @@ -58,12 +62,23 @@ class SettingsViewModelTest { revealFileListUseCase = RevealFileListUseCase(), localeInteractor = mockLocaleInteractor, adaptiveInteractor = adaptiveInteractor, + biometricInteractor = mockBiometricInteractor, coroutineDispatchers = coroutineDispatchers, ) + @Before + fun stubBiometricDefaults() { + // Mockito returns null for unstubbed suspend methods; unboxing the null Boolean inside + // updateSwitches() would NPE and route to ErrorDialog, breaking unrelated tests. + runBlocking { + Mockito.`when`(mockBiometricInteractor.canAuthenticate()).thenReturn(false) + Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(false) + } + } + @After fun tearDown() = runTest { - Mockito.reset(mockSafeRepo, mockSnackbarInteractor, mockRouter, mockAppVersionUseCase) + Mockito.reset(mockSafeRepo, mockSnackbarInteractor, mockRouter, mockAppVersionUseCase, mockBiometricInteractor) } @Test @@ -148,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) @@ -250,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) + } + } } diff --git a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/security/change/ChangeViewModelTest.kt b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/security/change/ChangeViewModelTest.kt index ff7e13686..51307e151 100644 --- a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/security/change/ChangeViewModelTest.kt +++ b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/security/change/ChangeViewModelTest.kt @@ -5,6 +5,7 @@ import app.cash.turbine.test import co.touchlab.kermit.Logger import com.softartdev.notedelight.CoroutineDispatchersStub import com.softartdev.notedelight.PrintLogWriter +import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.presentation.MainDispatcherRule @@ -12,6 +13,7 @@ import com.softartdev.notedelight.presentation.settings.security.FieldLabel import com.softartdev.notedelight.usecase.crypt.ChangePasswordUseCase import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals @@ -34,6 +36,7 @@ class ChangeViewModelTest { private val mockCheckPasswordUseCase = Mockito.mock(CheckPasswordUseCase::class.java) private val mockChangePasswordUseCase = Mockito.mock(ChangePasswordUseCase::class.java) + private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java) private val mockRouter = Mockito.mock(Router::class.java) private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java) private val coroutineDispatchers = CoroutineDispatchersStub( @@ -42,18 +45,22 @@ class ChangeViewModelTest { private val viewModel = ChangeViewModel( checkPasswordUseCase = mockCheckPasswordUseCase, changePasswordUseCase = mockChangePasswordUseCase, + biometricInteractor = mockBiometricInteractor, snackbarInteractor = mockSnackbarInteractor, router = mockRouter, coroutineDispatchers = coroutineDispatchers ) @Before - fun setUp() = Logger.setLogWriters(PrintLogWriter()) + fun setUp() { + Logger.setLogWriters(PrintLogWriter()) + runBlocking { Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(false) } + } @After fun tearDown() { Logger.setLogWriters() - Mockito.reset(mockCheckPasswordUseCase, mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter) + Mockito.reset(mockCheckPasswordUseCase, mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter, mockBiometricInteractor) } @Test diff --git a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/security/confirm/ConfirmViewModelTest.kt b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/security/confirm/ConfirmViewModelTest.kt index 5ad269875..c00c5fadf 100644 --- a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/security/confirm/ConfirmViewModelTest.kt +++ b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/security/confirm/ConfirmViewModelTest.kt @@ -5,12 +5,14 @@ import app.cash.turbine.test import co.touchlab.kermit.Logger import com.softartdev.notedelight.CoroutineDispatchersStub import com.softartdev.notedelight.PrintLogWriter +import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.presentation.MainDispatcherRule import com.softartdev.notedelight.presentation.settings.security.FieldLabel import com.softartdev.notedelight.usecase.crypt.ChangePasswordUseCase import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals @@ -32,6 +34,7 @@ class ConfirmViewModelTest { val mainDispatcherRule = MainDispatcherRule() private val mockChangePasswordUseCase = Mockito.mock(ChangePasswordUseCase::class.java) + private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java) private val mockRouter = Mockito.mock(Router::class.java) private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java) private val coroutineDispatchers = CoroutineDispatchersStub( @@ -39,18 +42,22 @@ class ConfirmViewModelTest { ) private val viewModel = ConfirmViewModel( changePasswordUseCase = mockChangePasswordUseCase, + biometricInteractor = mockBiometricInteractor, snackbarInteractor = mockSnackbarInteractor, router = mockRouter, coroutineDispatchers = coroutineDispatchers ) @Before - fun setUp() = Logger.setLogWriters(PrintLogWriter()) + fun setUp() { + Logger.setLogWriters(PrintLogWriter()) + runBlocking { Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(false) } + } @After fun tearDown() { Logger.setLogWriters() - Mockito.reset(mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter) + Mockito.reset(mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter, mockBiometricInteractor) } @Test diff --git a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/security/enter/EnterViewModelTest.kt b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/security/enter/EnterViewModelTest.kt index 44f9b0a5e..16af1ccba 100644 --- a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/security/enter/EnterViewModelTest.kt +++ b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/security/enter/EnterViewModelTest.kt @@ -5,6 +5,7 @@ import app.cash.turbine.test import co.touchlab.kermit.Logger import com.softartdev.notedelight.CoroutineDispatchersStub import com.softartdev.notedelight.PrintLogWriter +import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.presentation.MainDispatcherRule @@ -12,6 +13,7 @@ import com.softartdev.notedelight.presentation.settings.security.FieldLabel import com.softartdev.notedelight.usecase.crypt.ChangePasswordUseCase import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals @@ -34,6 +36,7 @@ class EnterViewModelTest { private val mockCheckPasswordUseCase = Mockito.mock(CheckPasswordUseCase::class.java) private val mockChangePasswordUseCase = Mockito.mock(ChangePasswordUseCase::class.java) + private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java) private val mockRouter = Mockito.mock(Router::class.java) private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java) private val coroutineDispatchers = CoroutineDispatchersStub( @@ -42,18 +45,22 @@ class EnterViewModelTest { private val viewModel = EnterViewModel( checkPasswordUseCase = mockCheckPasswordUseCase, changePasswordUseCase = mockChangePasswordUseCase, + biometricInteractor = mockBiometricInteractor, snackbarInteractor = mockSnackbarInteractor, router = mockRouter, coroutineDispatchers = coroutineDispatchers ) @Before - fun setUp() = Logger.setLogWriters(PrintLogWriter()) + fun setUp() { + Logger.setLogWriters(PrintLogWriter()) + runBlocking { Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(false) } + } @After fun tearDown() { Logger.setLogWriters() - Mockito.reset(mockCheckPasswordUseCase, mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter) + Mockito.reset(mockCheckPasswordUseCase, mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter, mockBiometricInteractor) } @Test 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..dbdcf2188 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 @@ -5,6 +5,12 @@ import androidx.compose.ui.autofill.AutofillManager import app.cash.turbine.test import com.softartdev.notedelight.StubEditable import com.softartdev.notedelight.anyObject +import androidx.fragment.app.FragmentActivity +import com.softartdev.notedelight.interactor.BiometricPlatformWrapper +import com.softartdev.notedelight.interactor.BiometricInteractor +import com.softartdev.notedelight.interactor.DecryptedPasswordResult +import com.softartdev.notedelight.interactor.SnackbarInteractor +import com.softartdev.notedelight.interactor.SnackbarMessage import com.softartdev.notedelight.navigation.AppNavGraph import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.presentation.MainDispatcherRule @@ -12,6 +18,8 @@ import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -27,21 +35,27 @@ class SignInViewModelTest { val mainDispatcherRule = MainDispatcherRule() private val mockCheckPasswordUseCase = Mockito.mock(CheckPasswordUseCase::class.java) + private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java) 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 fun setUp() { - signInViewModel = SignInViewModel(mockCheckPasswordUseCase, mockRouter) + signInViewModel = SignInViewModel( + mockCheckPasswordUseCase, mockBiometricInteractor, mockRouter, mockSnackbarInteractor + ) signInViewModel.autofillManager = mockAutofillManager } @Test fun showSignInForm() = runTest { signInViewModel.stateFlow.test { - assertEquals(SignInResult.ShowSignInForm, awaitItem()) + assertEquals(SignInResult(), awaitItem()) cancelAndIgnoreRemainingEvents() } } @@ -49,7 +63,7 @@ class SignInViewModelTest { @Test fun onSettingsClick() = runTest { signInViewModel.stateFlow.test { - assertEquals(SignInResult.ShowSignInForm, awaitItem()) + assertEquals(SignInResult(), awaitItem()) signInViewModel.onAction(SignInAction.OnSettingsClick) Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Settings) @@ -61,7 +75,7 @@ class SignInViewModelTest { @Test fun navMain() = runTest { signInViewModel.stateFlow.test { - assertEquals(SignInResult.ShowSignInForm, awaitItem()) + assertEquals(SignInResult(), awaitItem()) val pass = StubEditable("pass") Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true) @@ -76,10 +90,10 @@ class SignInViewModelTest { @Test fun showEmptyPassError() = runTest { signInViewModel.stateFlow.test { - assertEquals(SignInResult.ShowSignInForm, awaitItem()) + assertEquals(SignInResult(), awaitItem()) signInViewModel.onAction(SignInAction.OnSignInClick(pass = StubEditable(""))) - assertEquals(SignInResult.ShowEmptyPassError, awaitItem()) + assertEquals(SignInResult.ErrorType.EMPTY_PASSWORD, awaitItem().errorType) cancelAndIgnoreRemainingEvents() } @@ -88,12 +102,12 @@ class SignInViewModelTest { @Test fun showIncorrectPassError() = runTest { signInViewModel.stateFlow.test { - assertEquals(SignInResult.ShowSignInForm, awaitItem()) + assertEquals(SignInResult(), awaitItem()) val pass = StubEditable("pass") Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(false) signInViewModel.onAction(SignInAction.OnSignInClick(pass)) - assertEquals(SignInResult.ShowIncorrectPassError, awaitItem()) + assertEquals(SignInResult.ErrorType.INCORRECT_PASSWORD, awaitItem().errorType) cancelAndIgnoreRemainingEvents() } @@ -102,7 +116,7 @@ class SignInViewModelTest { @Test fun showError() = runTest { signInViewModel.stateFlow.test { - assertEquals(SignInResult.ShowSignInForm, awaitItem()) + assertEquals(SignInResult(), awaitItem()) val throwable = Throwable() Mockito.`when`(mockCheckPasswordUseCase(anyObject())).thenThrow(throwable) @@ -114,4 +128,55 @@ class SignInViewModelTest { cancelAndIgnoreRemainingEvents() } } -} \ No newline at end of file + + @Test + fun refreshBiometricVisibleWhenAvailable() = runTest { + Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(true) + Mockito.`when`(mockBiometricInteractor.canAuthenticate()).thenReturn(true) + signInViewModel.stateFlow.test { + assertFalse(awaitItem().biometricVisible) + signInViewModel.onAction(SignInAction.RefreshBiometric) + assertTrue(awaitItem().biometricVisible) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun biometricSignInSuccess() = runTest { + val pass = StubEditable("pass") + Mockito.`when`(mockBiometricInteractor.decryptStoredPassword(anyObject(), anyObject(), anyObject(), anyObject())) + .thenReturn(DecryptedPasswordResult.Success(pass)) + Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true) + signInViewModel.stateFlow.test { + assertEquals(SignInResult(), awaitItem()) + signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c", biometricPlatformWrapper)) + Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Main) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun biometricSignInUnavailableClearsState() = runTest { + Mockito.`when`(mockBiometricInteractor.decryptStoredPassword(anyObject(), anyObject(), anyObject(), anyObject())) + .thenReturn(DecryptedPasswordResult.Unavailable) + signInViewModel.stateFlow.test { + assertFalse(awaitItem().biometricVisible) + signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c", biometricPlatformWrapper)) + Mockito.verify(mockBiometricInteractor).clearStoredPassword() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun biometricSignInFailureShowsSnackbar() = runTest { + val errorMessage = "Biometric error" + Mockito.`when`(mockBiometricInteractor.decryptStoredPassword(anyObject(), anyObject(), anyObject(), anyObject())) + .thenReturn(DecryptedPasswordResult.Failure(errorMessage)) + signInViewModel.stateFlow.test { + assertEquals(SignInResult(), awaitItem()) + 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/interactor/SnackbarInteractor.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/SnackbarInteractor.kt index e5f96d6af..ff767145f 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/SnackbarInteractor.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/SnackbarInteractor.kt @@ -18,5 +18,6 @@ sealed interface SnackbarMessage { enum class SnackbarTextResource { SAVED, EMPTY, - DELETED + DELETED, + BIOMETRIC_DISABLED_PASSWORD_CHANGED, } 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 0692d92a2..4e16b5787 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 @@ -43,6 +43,12 @@ sealed interface AppNavGraph { @Serializable data object ChangePasswordDialog : 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/SettingsResult.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsResult.kt index d9ddfd2e1..89e6822dd 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 @@ -6,6 +6,8 @@ import com.softartdev.notedelight.model.SettingsCategory data class SettingsResult( val loading: Boolean = false, val encryption: Boolean = false, + val biometricEnabled: Boolean = false, + val biometricAvailable: Boolean = false, val fileListVisible: Boolean = false, val language: LanguageEnum = LanguageEnum.ENGLISH, val appVersion: String? = null, @@ -23,6 +25,7 @@ sealed interface SettingsAction { data object ChangeTheme : SettingsAction data object ChangeLanguage : SettingsAction data class ChangeEncryption(val checked: Boolean) : SettingsAction + data class ChangeBiometric(val checked: Boolean) : SettingsAction data object ChangePassword : SettingsAction data object ShowCipherVersion : SettingsAction data object ShowDatabasePath : 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..2f0fd2606 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 @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import com.softartdev.notedelight.interactor.AdaptiveInteractor +import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.LocaleInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarMessage @@ -37,6 +38,7 @@ class SettingsViewModel( private val revealFileListUseCase: RevealFileListUseCase, private val localeInteractor: LocaleInteractor, private val adaptiveInteractor: AdaptiveInteractor, + private val biometricInteractor: BiometricInteractor, private val coroutineDispatchers: CoroutineDispatchers, ) : ViewModel() { private val logger = Logger.withTag(this@SettingsViewModel::class.simpleName.toString()) @@ -62,6 +64,7 @@ class SettingsViewModel( is SettingsAction.ChangeTheme -> changeTheme() is SettingsAction.ChangeLanguage -> changeLanguage() is SettingsAction.ChangeEncryption -> changeEncryption(action.checked) + is SettingsAction.ChangeBiometric -> changeBiometric(action.checked) is SettingsAction.ChangePassword -> changePassword() is SettingsAction.ShowCipherVersion -> showCipherVersion() is SettingsAction.ShowDatabasePath -> showDatabasePath() @@ -78,6 +81,8 @@ class SettingsViewModel( mutableStateFlow.update { result -> result.copy( encryption = dbIsEncrypted, + biometricEnabled = biometricInteractor.hasStoredPassword(), + biometricAvailable = biometricInteractor.canAuthenticate(), language = localeInteractor.languageEnum, appVersion = appVersionUseCase.invoke() ) @@ -134,6 +139,29 @@ class SettingsViewModel( } } + private fun changeBiometric(checked: Boolean) = viewModelScope.launch { + try { + if (checked) { + router.navigate(route = AppNavGraph.BiometricEnrollDialog) + } else { + 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" } + } + } + private fun changePassword() = viewModelScope.launch { CountingIdlingRes.increment() mutableStateFlow.update(SettingsResult::showLoading) diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/change/ChangeViewModel.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/change/ChangeViewModel.kt index 372a9c14a..5a91d1d45 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/change/ChangeViewModel.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/change/ChangeViewModel.kt @@ -4,8 +4,10 @@ import androidx.compose.ui.autofill.AutofillManager import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarMessage +import com.softartdev.notedelight.interactor.SnackbarTextResource import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.presentation.settings.security.FieldLabel import com.softartdev.notedelight.usecase.crypt.ChangePasswordUseCase @@ -21,6 +23,7 @@ import kotlinx.coroutines.withContext class ChangeViewModel( private val checkPasswordUseCase: CheckPasswordUseCase, private val changePasswordUseCase: ChangePasswordUseCase, + private val biometricInteractor: BiometricInteractor, private val snackbarInteractor: SnackbarInteractor, private val router: Router, private val coroutineDispatchers: CoroutineDispatchers, @@ -28,7 +31,7 @@ class ChangeViewModel( private val logger = Logger.withTag(this@ChangeViewModel::class.simpleName.toString()) private val mutableStateFlow: MutableStateFlow = MutableStateFlow(ChangeResult()) val stateFlow: StateFlow = mutableStateFlow - var autofillManager: AutofillManager? = null + var autofillManager: AutofillManager? = null //TODO wrap in interactor for get rid of `androidx.compose` deps in presentation-modules fun onAction(action: ChangeAction) = when (action) { is ChangeAction.Cancel -> cancel() @@ -76,6 +79,14 @@ class ChangeViewModel( } checkPasswordUseCase(oldPassword) -> { changePasswordUseCase(oldPassword, newPassword) + if (biometricInteractor.hasStoredPassword()) { + biometricInteractor.clearStoredPassword() + snackbarInteractor.showMessage( + message = SnackbarMessage.Resource( + res = SnackbarTextResource.BIOMETRIC_DISABLED_PASSWORD_CHANGED + ) + ) + } autofillManager?.commit() withContext(coroutineDispatchers.main) { router.popBackStack() diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/confirm/ConfirmViewModel.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/confirm/ConfirmViewModel.kt index 5396cf3f5..94985f945 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/confirm/ConfirmViewModel.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/confirm/ConfirmViewModel.kt @@ -4,8 +4,10 @@ import androidx.compose.ui.autofill.AutofillManager import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarMessage +import com.softartdev.notedelight.interactor.SnackbarTextResource import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.presentation.settings.security.FieldLabel import com.softartdev.notedelight.usecase.crypt.ChangePasswordUseCase @@ -19,6 +21,7 @@ import kotlinx.coroutines.withContext class ConfirmViewModel( private val changePasswordUseCase: ChangePasswordUseCase, + private val biometricInteractor: BiometricInteractor, private val snackbarInteractor: SnackbarInteractor, private val router: Router, private val coroutineDispatchers: CoroutineDispatchers, @@ -28,7 +31,7 @@ class ConfirmViewModel( value = ConfirmResult() ) val stateFlow: StateFlow = mutableStateFlow - var autofillManager: AutofillManager? = null + var autofillManager: AutofillManager? = null //TODO wrap in interactor for get rid of `androidx.compose` deps in presentation-modules fun onAction(action: ConfirmAction) = when (action) { is ConfirmAction.Cancel -> cancel() @@ -68,6 +71,14 @@ class ConfirmViewModel( } else -> { changePasswordUseCase(null, password) + if (biometricInteractor.hasStoredPassword()) { + biometricInteractor.clearStoredPassword() + snackbarInteractor.showMessage( + message = SnackbarMessage.Resource( + res = SnackbarTextResource.BIOMETRIC_DISABLED_PASSWORD_CHANGED + ) + ) + } autofillManager?.commit() withContext(coroutineDispatchers.main) { router.popBackStack() diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/enter/EnterViewModel.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/enter/EnterViewModel.kt index 6a6a386e8..21391fb43 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/enter/EnterViewModel.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/enter/EnterViewModel.kt @@ -4,8 +4,10 @@ import androidx.compose.ui.autofill.AutofillManager import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarMessage +import com.softartdev.notedelight.interactor.SnackbarTextResource import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.presentation.settings.security.FieldLabel import com.softartdev.notedelight.usecase.crypt.ChangePasswordUseCase @@ -20,6 +22,7 @@ import kotlinx.coroutines.launch class EnterViewModel( private val checkPasswordUseCase: CheckPasswordUseCase, private val changePasswordUseCase: ChangePasswordUseCase, + private val biometricInteractor: BiometricInteractor, private val snackbarInteractor: SnackbarInteractor, private val router: Router, private val coroutineDispatchers: CoroutineDispatchers, @@ -27,7 +30,7 @@ class EnterViewModel( private val logger = Logger.withTag(this@EnterViewModel::class.simpleName.toString()) private val mutableStateFlow: MutableStateFlow = MutableStateFlow(EnterResult()) val stateFlow: StateFlow = mutableStateFlow - var autofillManager: AutofillManager? = null + var autofillManager: AutofillManager? = null //TODO wrap in interactor for get rid of `androidx.compose` deps in presentation-modules fun onAction(action: EnterAction) = when (action) { is EnterAction.Cancel -> cancel() @@ -58,6 +61,14 @@ class EnterViewModel( } checkPasswordUseCase(password) -> { changePasswordUseCase(password, null) + if (biometricInteractor.hasStoredPassword()) { + biometricInteractor.clearStoredPassword() + snackbarInteractor.showMessage( + message = SnackbarMessage.Resource( + res = SnackbarTextResource.BIOMETRIC_DISABLED_PASSWORD_CHANGED + ) + ) + } autofillManager?.commit() navigateUp() } 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..c50f98713 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 @@ -1,6 +1,15 @@ package com.softartdev.notedelight.presentation.signin +import com.softartdev.notedelight.interactor.BiometricPlatformWrapper + sealed interface SignInAction { data object OnSettingsClick : SignInAction data class OnSignInClick(val pass: CharSequence) : SignInAction + data object RefreshBiometric : SignInAction + data class OnBiometricClick( + val title: String, + val subtitle: String, + val negativeButton: String, + val biometricPlatformWrapper: BiometricPlatformWrapper + ) : SignInAction } 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 fb55778bb..cfca6d170 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,8 +1,16 @@ package com.softartdev.notedelight.presentation.signin -enum class SignInResult(val isError: Boolean = false) { - ShowSignInForm, - ShowProgress, - ShowEmptyPassError(isError = true), - ShowIncorrectPassError(isError = true) +data class SignInResult( + val loading: Boolean = false, + val errorType: ErrorType? = null, + val biometricVisible: Boolean = false, +) { + 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 fde142e82..34be71d41 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 @@ -4,50 +4,108 @@ import androidx.compose.ui.autofill.AutofillManager import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import com.softartdev.notedelight.interactor.BiometricInteractor +import com.softartdev.notedelight.interactor.BiometricPlatformWrapper +import com.softartdev.notedelight.interactor.DecryptedPasswordResult +import com.softartdev.notedelight.interactor.SnackbarInteractor +import com.softartdev.notedelight.interactor.SnackbarMessage import com.softartdev.notedelight.navigation.AppNavGraph import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase import com.softartdev.notedelight.util.CountingIdlingRes import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class SignInViewModel( private val checkPasswordUseCase: CheckPasswordUseCase, - private val router: Router + private val biometricInteractor: BiometricInteractor, + private val router: Router, + private val snackbarInteractor: SnackbarInteractor, ) : ViewModel() { private val logger = Logger.withTag(this@SignInViewModel::class.simpleName.toString()) - private val mutableStateFlow: MutableStateFlow = MutableStateFlow( - value = SignInResult.ShowSignInForm - ) + + private val mutableStateFlow: MutableStateFlow = MutableStateFlow(SignInResult()) val stateFlow: StateFlow = mutableStateFlow - var autofillManager: AutofillManager? = null + + var autofillManager: AutofillManager? = null //TODO wrap in interactor for get rid of androidx.compose deps in presentation-modules fun onAction(action: SignInAction) = when (action) { is SignInAction.OnSettingsClick -> router.navigateClearingBackStack(AppNavGraph.Settings) is SignInAction.OnSignInClick -> signIn(action.pass) + is SignInAction.RefreshBiometric -> refreshBiometric() + is SignInAction.OnBiometricClick -> signInWithBiometric( + title = action.title, + subtitle = action.subtitle, + negativeButton = action.negativeButton, + biometricPlatformWrapper = action.biometricPlatformWrapper, + ) } - private fun signIn(pass: CharSequence) = viewModelScope.launch { + private fun refreshBiometric() = viewModelScope.launch { + val visible: Boolean = biometricInteractor.hasStoredPassword() && biometricInteractor.canAuthenticate() + mutableStateFlow.update { it.copy(biometricVisible = visible) } + } + + private fun signInWithBiometric( + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, + ) = viewModelScope.launch { CountingIdlingRes.increment() - mutableStateFlow.value = SignInResult.ShowProgress + mutableStateFlow.update(SignInResult::hideErrors) + mutableStateFlow.update(SignInResult::showLoading) try { - mutableStateFlow.value = when { - pass.isEmpty() -> SignInResult.ShowEmptyPassError - checkPasswordUseCase(pass) -> { - autofillManager?.commit() - router.navigateClearingBackStack(AppNavGraph.Main) - SignInResult.ShowSignInForm + when (val res: DecryptedPasswordResult = biometricInteractor.decryptStoredPassword( + title = title, + subtitle = subtitle, + negativeButton = negativeButton, + biometricPlatformWrapper = biometricPlatformWrapper, + )) { + is DecryptedPasswordResult.Success -> signInInternal(pass = res.password) + is DecryptedPasswordResult.Cancelled -> Unit + is DecryptedPasswordResult.Unavailable -> { + biometricInteractor.clearStoredPassword() + mutableStateFlow.update(SignInResult::hideBiometric) + } + is DecryptedPasswordResult.Failure -> { + logger.e { res.message } + snackbarInteractor.showMessage(SnackbarMessage.Simple(res.message)) } - else -> SignInResult.ShowIncorrectPassError } + } catch (error: Throwable) { + logger.e(error) { "Error during biometric sign in" } + router.navigate(route = AppNavGraph.ErrorDialog(message = error.message)) + } finally { + mutableStateFlow.update(SignInResult::hideLoading) + CountingIdlingRes.decrement() + } + } + + private fun signIn(pass: CharSequence) = viewModelScope.launch { + CountingIdlingRes.increment() + mutableStateFlow.update(SignInResult::hideErrors) + mutableStateFlow.update(SignInResult::showLoading) + try { + signInInternal(pass) } catch (error: Throwable) { logger.e(error) { "Error during sign in" } autofillManager?.cancel() router.navigate(route = AppNavGraph.ErrorDialog(message = error.message)) - mutableStateFlow.value = SignInResult.ShowSignInForm } finally { + mutableStateFlow.update(SignInResult::hideLoading) CountingIdlingRes.decrement() } } + + private suspend fun signInInternal(pass: CharSequence) = when { + pass.isEmpty() -> mutableStateFlow.update(SignInResult::showEmptyPasswordError) + checkPasswordUseCase(pass) -> { + autofillManager?.commit() + router.navigateClearingBackStack(AppNavGraph.Main) + } + else -> mutableStateFlow.update(SignInResult::showIncorrectPasswordError) + } } diff --git a/core/test/ui/build.gradle.kts b/core/test/ui/build.gradle.kts index 3cbf8b48e..091427f0a 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 10e0ec6b6..149732d2e 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 daadbea94..bb038e504 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, @@ -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 { @@ -73,5 +73,3 @@ inline 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 000000000..c8b50f12b --- /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 9ec95df23..41e1949b0 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,8 @@ 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.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 @@ -51,6 +53,13 @@ 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 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 000000000..29b1b5aff --- /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 000000000..f1d1545bb --- /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 e33e6e383..8f2f5cf6f 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 582f82b28..a69bd182c 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/BiometricDisableConfirmationDialog.kt b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/dialog/BiometricDisableConfirmationDialog.kt new file mode 100644 index 000000000..fd680c1a8 --- /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/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 000000000..d0d79081d --- /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/ui/README.md b/core/ui/README.md index d7c67aa7e..bb01a7177 100644 --- a/core/ui/README.md +++ b/core/ui/README.md @@ -239,6 +239,11 @@ Platform-specific code is minimal (window configuration, system bars, etc.). - `core:domain` - Domain models - `core:data` (db-sqldelight or db-room) - Data layer - `core:presentation` - ViewModels +- `feature:biometric:domain` - BiometricInteractor (registered in platform DI modules) +- `feature:biometric:presentation` - BiometricEnrollViewModel (used by BiometricEnrollDialog) +- `feature:backup:domain` - Backup use cases +- `feature:backup:ui` - Backup screen composables +- `feature:console:domain`, `feature:console:presentation`, `feature:console:ui` - SQL Console feature - `compose.ui` - Compose UI runtime - `compose.material3` - Material 3 components - `compose.materialIconsExtended` - Material icons diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 97608101d..f2f185890 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -45,6 +45,8 @@ kotlin { implementation(projects.core.presentation) implementation(projects.feature.backup.domain) implementation(projects.feature.backup.ui) + implementation(projects.feature.biometric.domain) + implementation(projects.feature.biometric.presentation) implementation(projects.feature.fileExplorer.data) implementation(projects.feature.console.domain) implementation(projects.feature.console.presentation) 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 4e39a09a2..251fb9968 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 @@ -1,7 +1,9 @@ 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 @@ -12,4 +14,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) -} \ No newline at end of file + single { AndroidBiometricInteractor(get()) } +} diff --git a/core/ui/src/androidMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.android.kt b/core/ui/src/androidMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.android.kt new file mode 100644 index 000000000..51e92f270 --- /dev/null +++ b/core/ui/src/androidMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.android.kt @@ -0,0 +1,13 @@ +package com.softartdev.notedelight.ui + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.fragment.app.FragmentActivity +import com.softartdev.notedelight.interactor.BiometricPlatformWrapper + +@Composable +actual fun rememberBiometricPlatformWrapper(): BiometricPlatformWrapper { + val fragmentActivity = LocalActivity.current as FragmentActivity + return remember(key1 = fragmentActivity) { BiometricPlatformWrapper(fragmentActivity) } +} diff --git a/core/ui/src/commonMain/composeResources/values-ru/strings.xml b/core/ui/src/commonMain/composeResources/values-ru/strings.xml index a75167cb1..a2b7e424d 100644 --- a/core/ui/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-ru/strings.xml @@ -86,4 +86,17 @@ Введите SQL-запрос Выполнить Подставить + Вход по биометрии + Разблокировка заметок + Подтвердите биометрию для расшифровки пароля + Отмена + По биометрии + Включить вход по биометрии + Подтвердите пароль, чтобы разрешить вход по биометрии. + Отключить вход по биометрии? + Чтобы снова включить вход по биометрии, потребуется заново ввести пароль. + Отключить + Вход по биометрии отключён — снова включите его в настройках. + Не удалось пройти биометрию + Биометрия недоступна diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index d8fa04fa2..3a7e639f9 100644 --- a/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/core/ui/src/commonMain/composeResources/values/strings.xml @@ -86,4 +86,17 @@ Enter a SQL statement Run Autofill + Enable biometric sign-in + Unlock your notes + Use biometrics to decrypt your password + Cancel + 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 8ee11d53b..4fa916598 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,8 @@ 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 import com.softartdev.notedelight.ui.dialog.security.EnterPasswordDialog @@ -100,6 +102,12 @@ fun App( dialog { ChangePasswordDialog(changeViewModel = koinViewModel()) } + dialog { + BiometricEnrollDialog(biometricEnrollViewModel = koinViewModel()) + } + dialog { + BiometricDisableConfirmationDialog(biometricDisableViewModel = koinViewModel()) + } dialog { backStackEntry: NavBackStackEntry -> ErrorDialog( message = backStackEntry.toRoute().message, @@ -117,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 f02a0a16b..e71de8052 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,8 @@ 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 import com.softartdev.notedelight.presentation.settings.security.enter.EnterViewModel @@ -76,6 +78,8 @@ val viewModelModule: Module = module { viewModelOf(::EnterViewModel) 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/interactor/SnackbarInteractorImpl.kt b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/interactor/SnackbarInteractorImpl.kt index 8d6c5b6fb..d1c26c4d1 100644 --- a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/interactor/SnackbarInteractorImpl.kt +++ b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/interactor/SnackbarInteractorImpl.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch import notedelight.core.ui.generated.resources.Res +import notedelight.core.ui.generated.resources.biometric_disabled_due_to_password_change import notedelight.core.ui.generated.resources.copy import notedelight.core.ui.generated.resources.note_deleted import notedelight.core.ui.generated.resources.note_empty @@ -52,6 +53,7 @@ class SnackbarInteractorImpl : SnackbarInteractor { SnackbarTextResource.SAVED -> Res.string.note_saved SnackbarTextResource.EMPTY -> Res.string.note_empty SnackbarTextResource.DELETED -> Res.string.note_deleted + SnackbarTextResource.BIOMETRIC_DISABLED_PASSWORD_CHANGED -> Res.string.biometric_disabled_due_to_password_change } var text: String = getString(resource = resource) if (message.suffix.isNotEmpty()) { diff --git a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.kt b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.kt new file mode 100644 index 000000000..bcafe5949 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.kt @@ -0,0 +1,17 @@ +package com.softartdev.notedelight.ui + +import androidx.compose.runtime.Composable +import com.softartdev.notedelight.interactor.BiometricPlatformWrapper + +/** + * Returns a [BiometricPlatformWrapper] bound to the current composition context. + * + * On Android the wrapper carries `LocalActivity.current` (cast to `FragmentActivity`) so that + * [com.softartdev.notedelight.interactor.BiometricInteractor] can create a + * `BiometricPrompt` against the correct host. + * + * On all other platforms (iOS/Desktop/Web) biometrics are handled natively without an Android + * Activity, so the returned [BiometricPlatformWrapper] is an empty stub. + */ +@Composable +expect fun rememberBiometricPlatformWrapper(): BiometricPlatformWrapper 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 000000000..2c876a71c --- /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/ui/dialog/security/BiometricEnrollDialog.kt b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricEnrollDialog.kt new file mode 100644 index 000000000..c14512cd4 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricEnrollDialog.kt @@ -0,0 +1,100 @@ +package com.softartdev.notedelight.ui.dialog.security + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.softartdev.notedelight.interactor.BiometricPlatformWrapper +import com.softartdev.notedelight.presentation.settings.security.biometric.BiometricEnrollAction +import com.softartdev.notedelight.presentation.settings.security.biometric.BiometricEnrollResult +import com.softartdev.notedelight.presentation.settings.security.biometric.BiometricEnrollViewModel +import com.softartdev.notedelight.ui.PasswordField +import com.softartdev.notedelight.ui.rememberBiometricPlatformWrapper +import com.softartdev.notedelight.ui.PasswordSaveButton +import com.softartdev.notedelight.ui.dialog.PreviewDialog +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 notedelight.core.ui.generated.resources.Res +import notedelight.core.ui.generated.resources.biometric_enroll_dialog_subtitle +import notedelight.core.ui.generated.resources.biometric_enroll_dialog_title +import notedelight.core.ui.generated.resources.biometric_prompt_negative_button +import notedelight.core.ui.generated.resources.biometric_prompt_subtitle +import notedelight.core.ui.generated.resources.biometric_prompt_title +import notedelight.core.ui.generated.resources.cancel +import notedelight.core.ui.generated.resources.enter_password +import org.jetbrains.compose.resources.stringResource + +@Composable +fun BiometricEnrollDialog(biometricEnrollViewModel: BiometricEnrollViewModel) { + val result: BiometricEnrollResult by biometricEnrollViewModel.stateFlow.collectAsState() + ShowBiometricEnrollDialog(result, biometricEnrollViewModel::onAction) +} + +@Composable +fun ShowBiometricEnrollDialog( + result: BiometricEnrollResult, + onAction: (action: BiometricEnrollAction) -> Unit = {}, + title: String = stringResource(Res.string.biometric_prompt_title), + subtitle: String = stringResource(Res.string.biometric_prompt_subtitle), + negative: String = stringResource(Res.string.biometric_prompt_negative_button), + bio: BiometricPlatformWrapper = rememberBiometricPlatformWrapper() +) = AlertDialog( + modifier = Modifier.testTag(BIOMETRIC_ENROLL_DIALOG_TAG), + title = { Text(text = stringResource(Res.string.biometric_enroll_dialog_title)) }, + text = { + Column { + Text(text = stringResource(Res.string.biometric_enroll_dialog_subtitle)) + Spacer(modifier = Modifier.height(8.dp)) + if (result.loading) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + PasswordField( + modifier = Modifier.fillMaxWidth(), + password = result.password, + onPasswordChange = { onAction(BiometricEnrollAction.OnEditPassword(it)) }, + label = result.fieldLabel.resString, + isError = result.isError, + contentDescription = stringResource(Res.string.enter_password), + imeAction = ImeAction.Done, + keyboardActions = KeyboardActions { + onAction(BiometricEnrollAction.OnEnrollClick(title, subtitle, negative, bio)) + }, + labelTag = BIOMETRIC_ENROLL_DIALOG_LABEL_TAG, + visibilityTag = BIOMETRIC_ENROLL_DIALOG_VISIBILITY_TAG, + fieldTag = BIOMETRIC_ENROLL_DIALOG_FIELD_TAG, + ) + } + }, + confirmButton = { + PasswordSaveButton( + tag = BIOMETRIC_ENROLL_DIALOG_SAVE_BUTTON_TAG, + onClick = { onAction(BiometricEnrollAction.OnEnrollClick(title, subtitle, negative, bio)) }, + ) + }, + dismissButton = { + Button(onClick = { onAction(BiometricEnrollAction.Cancel) }) { + Text(stringResource(Res.string.cancel)) + } + }, + onDismissRequest = { onAction(BiometricEnrollAction.Cancel) }, +) + +@Preview +@Composable +fun PreviewBiometricEnrollDialog() = PreviewDialog { + ShowBiometricEnrollDialog(BiometricEnrollResult(loading = true)) +} diff --git a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/settings/detail/SettingsDetailScreen.kt b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/settings/detail/SettingsDetailScreen.kt index e84a216fb..da891ed6b 100644 --- a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/settings/detail/SettingsDetailScreen.kt +++ b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/settings/detail/SettingsDetailScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Commit import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.FileUpload +import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Lock @@ -63,6 +64,7 @@ import com.softartdev.notedelight.repository.SafeRepo import com.softartdev.notedelight.ui.NavBackHandler import com.softartdev.notedelight.ui.SettingsDetailPanePlaceholder import com.softartdev.notedelight.ui.icon.FileLock +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 @@ -76,6 +78,7 @@ import notedelight.core.ui.generated.resources.Res import notedelight.core.ui.generated.resources.language import notedelight.core.ui.generated.resources.pref_subtitle_open_github import notedelight.core.ui.generated.resources.pref_title_check_cipher_version +import notedelight.core.ui.generated.resources.pref_title_enable_biometric import notedelight.core.ui.generated.resources.pref_title_enable_encryption import notedelight.core.ui.generated.resources.pref_title_export_db import notedelight.core.ui.generated.resources.pref_title_file_list @@ -203,6 +206,24 @@ private fun SecurityPreferences(result: SettingsResult, onAction: (SettingsActio vector = Icons.Default.Password, onClick = { onAction(SettingsAction.ChangePassword) } ) + if (result.encryption && result.biometricAvailable) { + val enableBiometricPrefTitle = stringResource(Res.string.pref_title_enable_biometric) + Preference( + modifier = Modifier.semantics { + contentDescription = enableBiometricPrefTitle + toggleableState = ToggleableState(result.biometricEnabled) + semanticsTestTag = ENABLE_BIOMETRIC_SWITCH_TAG + }, + title = enableBiometricPrefTitle, + vector = Icons.Default.Fingerprint, + onClick = { onAction(SettingsAction.ChangeBiometric(!result.biometricEnabled)) } + ) { + Switch( + checked = result.biometricEnabled, + onCheckedChange = { onAction(SettingsAction.ChangeBiometric(it)) } + ) + } + } Preference( title = stringResource(Res.string.pref_title_check_cipher_version), vector = Icons.Filled.FileLock, 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..9086c6ca3 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 @@ -8,10 +8,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -29,11 +32,14 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.ImeAction 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.SignInResult import com.softartdev.notedelight.presentation.signin.SignInViewModel import com.softartdev.notedelight.ui.PasswordField import com.softartdev.notedelight.ui.TooltipIconButton +import com.softartdev.notedelight.ui.rememberBiometricPlatformWrapper +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 @@ -41,6 +47,10 @@ import com.softartdev.notedelight.util.SIGN_IN_PASSWORD_VISIBILITY_TAG import com.softartdev.notedelight.util.SIGN_IN_SETTINGS_BUTTON_TAG import notedelight.core.ui.generated.resources.Res import notedelight.core.ui.generated.resources.app_name +import notedelight.core.ui.generated.resources.biometric_prompt_negative_button +import notedelight.core.ui.generated.resources.biometric_prompt_subtitle +import notedelight.core.ui.generated.resources.biometric_prompt_title +import notedelight.core.ui.generated.resources.biometric_signin_button import notedelight.core.ui.generated.resources.empty_password import notedelight.core.ui.generated.resources.enter_password import notedelight.core.ui.generated.resources.incorrect_password @@ -57,16 +67,20 @@ fun SignInScreen(signInViewModel: SignInViewModel) { LaunchedEffect(key1 = signInViewModel, key2 = autofillManager) { signInViewModel.autofillManager = autofillManager } + LaunchedEffect(signInViewModel) { + signInViewModel.onAction(SignInAction.RefreshBiometric) + } SignInScreenBody( - showLoading = signInResultState.value == SignInResult.ShowProgress, + showLoading = signInResultState.value.loading, passwordState = passwordState, - labelResource = when (signInResultState.value) { - SignInResult.ShowEmptyPassError -> Res.string.empty_password - SignInResult.ShowIncorrectPassError -> Res.string.incorrect_password - else -> Res.string.enter_password + labelResource = when (signInResultState.value.errorType) { + SignInResult.ErrorType.EMPTY_PASSWORD -> Res.string.empty_password + SignInResult.ErrorType.INCORRECT_PASSWORD -> Res.string.incorrect_password + null -> Res.string.enter_password }, - isError = signInResultState.value.isError, - onAction = signInViewModel::onAction + isError = signInResultState.value.errorType != null, + biometricVisible = signInResultState.value.biometricVisible, + onAction = signInViewModel::onAction, ) } @@ -77,6 +91,7 @@ fun SignInScreenBody( passwordState: MutableState = mutableStateOf("password"), labelResource: StringResource = Res.string.enter_password, isError: Boolean = false, + biometricVisible: Boolean = false, onAction: (SignInAction) -> Unit = {}, ) = Scaffold( topBar = { @@ -117,10 +132,30 @@ fun SignInScreenBody( .padding(top = 24.dp), onClick = { onAction(SignInAction.OnSignInClick(passwordState.value)) }, ) { Text(text = stringResource(Res.string.sign_in)) } + if (biometricVisible) { + val title = stringResource(Res.string.biometric_prompt_title) + val subtitle = stringResource(Res.string.biometric_prompt_subtitle) + val negative = stringResource(Res.string.biometric_prompt_negative_button) + val bio: BiometricPlatformWrapper = rememberBiometricPlatformWrapper() + OutlinedButton( + modifier = Modifier + .testTag(SIGN_IN_BIOMETRIC_BUTTON_TAG) + .fillMaxWidth() + .padding(top = 8.dp), + onClick = { onAction(SignInAction.OnBiometricClick(title, subtitle, negative, bio)) }, + ) { + Icon( + imageVector = Icons.Default.Fingerprint, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) + Text(text = stringResource(Res.string.biometric_signin_button)) + } + } } } } @Preview @Composable -fun PreviewSignInScreen() = SignInScreenBody() \ No newline at end of file +fun PreviewSignInScreen() = SignInScreenBody(biometricVisible = true) 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..22611586c 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] @@ -76,6 +77,13 @@ const val SETTINGS_CATEGORY_SECURITY_TAG = "SETTINGS_CATEGORY_SECURITY_TAG" const val SETTINGS_CATEGORY_BACKUP_TAG = "SETTINGS_CATEGORY_BACKUP_TAG" const val SETTINGS_CATEGORY_INFO_TAG = "SETTINGS_CATEGORY_INFO_TAG" const val ENABLE_ENCRYPTION_SWITCH_TAG = "ENABLE_ENCRYPTION_SWITCH_TAG" +const val ENABLE_BIOMETRIC_SWITCH_TAG = "ENABLE_BIOMETRIC_SWITCH_TAG" +const val BIOMETRIC_ENROLL_DIALOG_TAG = "BIOMETRIC_ENROLL_DIALOG_TAG" +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/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 5ac6c6996..e86895e76 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 @@ -1,7 +1,9 @@ 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 @@ -12,4 +14,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) + singleOf(::IosBiometricInteractor) } diff --git a/core/ui/src/iosMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.ios.kt b/core/ui/src/iosMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.ios.kt new file mode 100644 index 000000000..b85859475 --- /dev/null +++ b/core/ui/src/iosMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.ios.kt @@ -0,0 +1,10 @@ +package com.softartdev.notedelight.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.softartdev.notedelight.interactor.BiometricPlatformWrapper + +@Composable +actual fun rememberBiometricPlatformWrapper(): BiometricPlatformWrapper = remember { + return@remember BiometricPlatformWrapper() +} 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 5ac6c6996..f02e75edb 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 @@ -1,7 +1,9 @@ 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 @@ -12,4 +14,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) + singleOf(::JvmBiometricInteractor) } diff --git a/core/ui/src/jvmMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.jvm.kt b/core/ui/src/jvmMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.jvm.kt new file mode 100644 index 000000000..b85859475 --- /dev/null +++ b/core/ui/src/jvmMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.jvm.kt @@ -0,0 +1,10 @@ +package com.softartdev.notedelight.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.softartdev.notedelight.interactor.BiometricPlatformWrapper + +@Composable +actual fun rememberBiometricPlatformWrapper(): BiometricPlatformWrapper = remember { + return@remember BiometricPlatformWrapper() +} 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 5ac6c6996..148fac91e 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 @@ -1,7 +1,9 @@ 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 @@ -12,4 +14,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) + singleOf(::WebBiometricInteractor) } diff --git a/core/ui/src/wasmJsMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.wasmJs.kt b/core/ui/src/wasmJsMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.wasmJs.kt new file mode 100644 index 000000000..b85859475 --- /dev/null +++ b/core/ui/src/wasmJsMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.wasmJs.kt @@ -0,0 +1,10 @@ +package com.softartdev.notedelight.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.softartdev.notedelight.interactor.BiometricPlatformWrapper + +@Composable +actual fun rememberBiometricPlatformWrapper(): BiometricPlatformWrapper = remember { + return@remember BiometricPlatformWrapper() +} diff --git a/feature/biometric/domain/README.md b/feature/biometric/domain/README.md new file mode 100644 index 000000000..f8656bde3 --- /dev/null +++ b/feature/biometric/domain/README.md @@ -0,0 +1,132 @@ +# feature:biometric:domain + +Biometric authentication domain module — platform-agnostic contract plus platform-specific implementations. + +## Overview + +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 + +### `BiometricInteractor` + +```kotlin +expect class BiometricInteractor { + suspend fun canAuthenticate(): Boolean + suspend fun hasStoredPassword(): Boolean + suspend fun encryptAndStorePassword( + password: CharSequence, + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, + ): BiometricResult + suspend fun decryptStoredPassword( + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, + ): DecryptedPasswordResult + suspend fun clearStoredPassword() +} +``` + +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` + +```kotlin +sealed interface BiometricResult { + data object Success : BiometricResult + data object Failed : BiometricResult + data object Cancelled : BiometricResult + data object Unavailable : BiometricResult + data class Error(val message: String) : BiometricResult +} +``` + +### `DecryptedPasswordResult` + +```kotlin +sealed interface DecryptedPasswordResult { + data class Success(val password: CharSequence) : DecryptedPasswordResult + data object Cancelled : DecryptedPasswordResult // user dismissed the prompt + data object Unavailable : DecryptedPasswordResult // no stored credential / key invalidated + data class Failure(val message: String) : DecryptedPasswordResult // unrecoverable error +} +``` + +`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`. + +### `BiometricPlatformWrapper` + +```kotlin +expect class BiometricPlatformWrapper +// Android actual: actual class BiometricPlatformWrapper(val activity: FragmentActivity) +// iOS/JVM/wasmJs: actual class BiometricPlatformWrapper (empty stub) +``` + +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 + +### Android (`androidMain`) + +**Password storage** uses two independent layers of protection: + +1. **Android Keystore (AES-256-GCM)** — A hardware-backed symmetric key (`notedelight_biometric_key`) is generated with `setUserAuthenticationRequired(true)` and (on API 24+) `setInvalidatedByBiometricEnrollment(true)`. The key can only be used after a successful biometric authentication and never leaves the secure hardware element. + +2. **DataStore Preferences** (`BiometricCredentialsStore`)— The *encrypted* output of AES-GCM (ciphertext + IV, both Base64-encoded) is stored via DataStore Preferences. + +> **Is the DataStore encrypted?** No — DataStore writes a plain binary Protobuf file on disk and applies no application-level encryption. However, the *values* stored inside it are already opaque AES-GCM ciphertext — they cannot be decrypted without the Android Keystore key, which is hardware-bound and biometric-gated and never leaves the secure element. An attacker with raw filesystem access would obtain unintelligible bytes with no way to recover the plaintext password without also defeating the device's secure hardware. + +**Enroll flow** (`encryptAndStorePassword`): +1. The existing Keystore key is reused, or a new one is generated. +2. A `Cipher` is initialised in `ENCRYPT_MODE` with the Keystore key. +3. `BiometricPrompt` shows the system biometric UI (via `runPrompt`); the `Cipher` is passed as a `CryptoObject` so Android can attest the authentication. +4. On success the `CryptoObject`'s cipher is used to encrypt the password bytes (AES-256-GCM). +5. `BiometricCredentialsStore.save(ciphertext, iv)` persists both Base64-encoded values. + +**Sign-in flow** (`decryptStoredPassword`): +1. `BiometricCredentialsStore.load()` retrieves the stored `(ciphertext, iv)` pair; returns `Unavailable` if absent. +2. The Keystore key is looked up; if absent or permanently invalidated (`KeyPermanentlyInvalidatedException`), the stored credential is cleared and `Unavailable` is returned. +3. A `Cipher` is initialised in `DECRYPT_MODE` with the key + stored IV via `GCMParameterSpec`. +4. `BiometricPrompt` shows the biometric UI; on success the plaintext password is decrypted and returned as `DecryptedPasswordResult.Success`. +5. User cancels → `Cancelled`; hardware unavailable / key gone → `Unavailable`; other error → `Failure(message)`. + +**Key design decisions**: +- `BiometricPrompt.authenticate()` must run on the main thread — `runPrompt()` uses `withContext(Dispatchers.Main.immediate)`. +- `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): +Encapsulates all DataStore read/write operations. Exposes `hasCredentials()`, `load(): Pair?`, `save(ciphertext, iv)`, `clear()`. + +### iOS (`iosMain`) + +**Password storage** uses a single layer: the iOS **Keychain** with `kSecAccessControlBiometryCurrentSet`. + +1. `encryptAndStorePassword`: + - `LAContext.evaluatePolicy(LAPolicyDeviceOwnerAuthenticationWithBiometrics)` is called first to obtain explicit user consent. + - On success, the **plaintext** password bytes are stored directly in a Keychain item protected by `kSecAccessControlBiometryCurrentSet`. + - The access control flag means the item is automatically invalidated if the device's biometric enrollment changes (new finger/face added or removed). + +2. `decryptStoredPassword`: + - An `LAContext` is configured with the prompt strings and passed as `kSecUseAuthenticationContext` in the `SecItemCopyMatching` query. + - The Keychain enforces biometric authentication implicitly when reading the protected item. + - On success the raw `NSData` is decoded as UTF-8 and returned as `DecryptedPasswordResult.Success`. + - `errSecItemNotFound` → `Unavailable` (item gone or biometry enrollment changed); other statuses are mapped via `mapKeychainStatus`. + +> **Security note**: On iOS the password is **not** encrypted at the application layer — it is stored as plaintext bytes inside a hardware-protected Keychain item. Security is provided entirely by the Secure Enclave and `kSecAccessControlBiometryCurrentSet`. The flag ensures the item is bound to the current biometric set and invalidated on any enrollment change. + +### Desktop/Web (`jvmMain`, `wasmJsMain`) + +Stub actuals — `canAuthenticate()` returns `false`, all operations return `Unavailable` or `BiometricResult.Unavailable`. Biometric is not supported on Desktop or Web. + +## Dependencies + +- `kotlinx-coroutines-core` (common) +- `kermit` logging (common) +- `androidx.biometric:biometric:1.1.0` (Android only) +- `androidx.appcompat:appcompat` (Android only — for `FragmentActivity`) +- `androidx.datastore:datastore-preferences` (Android only — `BiometricCredentialsStore`) diff --git a/feature/biometric/domain/build.gradle.kts b/feature/biometric/domain/build.gradle.kts new file mode 100644 index 000000000..e8edf526d --- /dev/null +++ b/feature/biometric/domain/build.gradle.kts @@ -0,0 +1,50 @@ +@file:OptIn(ExperimentalWasmDsl::class, ExperimentalKotlinGradlePluginApi::class) + +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.gradle.convention) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) +} + +kotlin { + jvmToolchain(libs.versions.jdk.get().toInt()) + jvm() + android { + namespace = "com.softartdev.notedelight.feature.biometric.domain" + compileSdk = libs.versions.compileSdk.get().toInt() + minSdk = libs.versions.minSdk.get().toInt() + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(libs.versions.jdk.get())) + } + } + iosArm64() + iosSimulatorArm64() + wasmJs { + browser() + } + sourceSets.forEach { + it.dependencies { + implementation(project.dependencies.enforcedPlatform(libs.coroutines.bom)) + } + } + sourceSets { + commonMain.dependencies { + implementation(libs.coroutines.core) + implementation(libs.kermit) + } + androidMain.dependencies { + implementation(libs.androidx.appcompat) + implementation(libs.androidx.biometric) + implementation(libs.androidx.datastore.preferences) + } + } + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") +} + +dependencies { + coreLibraryDesugaring(libs.desugar) +} diff --git a/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/AndroidBiometricInteractor.kt b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/AndroidBiometricInteractor.kt new file mode 100644 index 000000000..f1cb60cc2 --- /dev/null +++ b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/AndroidBiometricInteractor.kt @@ -0,0 +1,206 @@ +package com.softartdev.notedelight.interactor + +import android.content.Context +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import kotlin.coroutines.resume + +class AndroidBiometricInteractor(context: Context) : BiometricInteractor { + private val logger = Logger.withTag("BiometricInteractor") + private val appContext: Context = context.applicationContext + private val credentialsStore = BiometricCredentialsStore(appContext) + + override suspend fun canAuthenticate(): Boolean = BiometricManager + .from(appContext) + .canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS + + override suspend fun hasStoredPassword(): Boolean = credentialsStore.hasCredentials() + + override suspend fun encryptAndStorePassword( + password: CharSequence, + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, + ): BiometricResult { + val activity: FragmentActivity = biometricPlatformWrapper.activity + clearStoredPassword() + val secretKey: SecretKey = try { + createOrGetKey() + } catch (t: Throwable) { + logger.e(t) { "Keystore failure" } + return BiometricResult.Error(t.message ?: "Keystore failure") + } + val cipher: Cipher = Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.ENCRYPT_MODE, secretKey) + } + return when (val auth: PromptOutcome = runPrompt(activity, cipher, title, subtitle, negativeButton)) { + is PromptOutcome.Authenticated -> { + val out: ByteArray = auth.cipher.doFinal(password.toString().toByteArray(Charsets.UTF_8)) + credentialsStore.save( + ciphertext = Base64.encodeToString(out, Base64.NO_WRAP), + iv = Base64.encodeToString(auth.cipher.iv, Base64.NO_WRAP), + ) + BiometricResult.Success + } + is PromptOutcome.Failure -> auth.result + } + } + + override suspend fun decryptStoredPassword( + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, + ): DecryptedPasswordResult { + val (ciphertextStr, ivStr) = credentialsStore.load() + ?: return DecryptedPasswordResult.Unavailable + val activity: FragmentActivity = biometricPlatformWrapper.activity + val ciphertext: ByteArray = Base64.decode(ciphertextStr, Base64.NO_WRAP) + val iv: ByteArray = Base64.decode(ivStr, Base64.NO_WRAP) + val secretKey: SecretKey = try { + existingKey() ?: run { + clearStoredPassword() + return DecryptedPasswordResult.Unavailable + } + } catch (t: KeyPermanentlyInvalidatedException) { + logger.e(t) { "Key permanently invalidated" } + clearStoredPassword() + return DecryptedPasswordResult.Unavailable + } catch (t: Throwable) { + logger.e(t) { "Keystore failure" } + return DecryptedPasswordResult.Failure(t.message ?: "Keystore failure") + } + val cipher: Cipher = try { + Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(GCM_TAG_BITS, iv)) + } + } catch (t: KeyPermanentlyInvalidatedException) { + logger.e(t) { "Key permanently invalidated" } + clearStoredPassword() + return DecryptedPasswordResult.Unavailable + } catch (t: Throwable) { + logger.e(t) { "Cipher init failed" } + return DecryptedPasswordResult.Failure(t.message ?: "Cipher init failed") + } + return when (val auth: PromptOutcome = runPrompt(activity, cipher, title, subtitle, negativeButton)) { + is PromptOutcome.Authenticated -> { + val plain: ByteArray = auth.cipher.doFinal(ciphertext) + DecryptedPasswordResult.Success(plain.toString(Charsets.UTF_8)) + } + is PromptOutcome.Failure -> when (auth.result) { + BiometricResult.Cancelled -> DecryptedPasswordResult.Cancelled + BiometricResult.Unavailable -> DecryptedPasswordResult.Unavailable + is BiometricResult.Error -> DecryptedPasswordResult.Failure(auth.result.message) + else -> DecryptedPasswordResult.Failure(auth.result.toString()) + } + } + } + + override suspend fun clearStoredPassword() { + credentialsStore.clear() + runCatching { + KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }.deleteEntry(KEY_ALIAS) + } + } + + private fun existingKey(): SecretKey? { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + return keyStore.getKey(KEY_ALIAS, null) as? SecretKey + } + + private fun createOrGetKey(): SecretKey { + existingKey()?.let { return it } + val builder = KeyGenParameterSpec + .Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setUserAuthenticationRequired(true) + // setInvalidatedByBiometricEnrollment requires API 24; minSdk is 23. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.setInvalidatedByBiometricEnrollment(true) + } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + generator.init(builder.build()) + return generator.generateKey() + } + + // BiometricPrompt.authenticate(...) must run on the main thread; ViewModels invoke us from + // Dispatchers.IO, so we hop to Main.immediate before showing the prompt. + private suspend fun runPrompt( + activity: FragmentActivity, + cipher: Cipher, + title: String, + subtitle: String, + negativeButton: String, + ): PromptOutcome = withContext(Dispatchers.Main.immediate) { + suspendCancellableCoroutine { continuation -> + val executor = ContextCompat.getMainExecutor(appContext) + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + val resultCipher: Cipher? = result.cryptoObject?.cipher + val outcome: PromptOutcome = if (resultCipher == null) { + PromptOutcome.Failure(BiometricResult.Error("Missing CryptoObject")) + } else { + PromptOutcome.Authenticated(resultCipher) + } + if (continuation.isActive) continuation.resume(outcome) + } + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + val mapped: BiometricResult = when (errorCode) { + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_NEGATIVE_BUTTON, + BiometricPrompt.ERROR_CANCELED -> BiometricResult.Cancelled + BiometricPrompt.ERROR_NO_BIOMETRICS, + BiometricPrompt.ERROR_HW_NOT_PRESENT, + BiometricPrompt.ERROR_HW_UNAVAILABLE -> BiometricResult.Unavailable + else -> BiometricResult.Error(errString.toString()) + } + if (continuation.isActive) continuation.resume(PromptOutcome.Failure(mapped)) + } + override fun onAuthenticationFailed() { + // Wrong fingerprint; the system gives the user another try, so we wait for + // the terminal onAuthenticationError callback before resuming. + } + } + val prompt = BiometricPrompt(activity, executor, callback) + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setNegativeButtonText(negativeButton) + .setAllowedAuthenticators(BIOMETRIC_STRONG) + .build() + continuation.invokeOnCancellation { runCatching { prompt.cancelAuthentication() } } + prompt.authenticate(info, BiometricPrompt.CryptoObject(cipher)) + } + } + + private sealed interface PromptOutcome { + data class Authenticated(val cipher: Cipher) : PromptOutcome + data class Failure(val result: BiometricResult) : PromptOutcome + } + + companion object { + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val KEY_ALIAS = "notedelight_biometric_key" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val GCM_TAG_BITS = 128 + } +} diff --git a/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricCredentialsStore.kt b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricCredentialsStore.kt new file mode 100644 index 000000000..5978c1f4d --- /dev/null +++ b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricCredentialsStore.kt @@ -0,0 +1,63 @@ +package com.softartdev.notedelight.interactor + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStoreFile +import kotlinx.coroutines.flow.first + +/** + * Stores and retrieves the biometric-encrypted credential pair (Base64-encoded AES-GCM ciphertext + * and IV) using [DataStore] Preferences. + * + * **Is the DataStore itself encrypted?** No. DataStore stores data in a plain binary Protobuf file + * on disk. However, the *values* stored here are already opaque ciphertext — the user's database + * password is encrypted with a hardware-backed Android Keystore key (AES-256-GCM) that can only + * be used after a successful biometric authentication. An attacker with raw file-system access + * would see Base64-encoded bytes but could not decrypt them without the Keystore key, which never + * leaves the secure hardware. + */ +internal class BiometricCredentialsStore(context: Context) { + + private val dataStore: DataStore = PreferenceDataStoreFactory.create( + produceFile = { context.applicationContext.preferencesDataStoreFile(PREFS_NAME) } + ) + + /** Returns `true` when both the ciphertext and IV entries are present. */ + suspend fun hasCredentials(): Boolean { + val prefs: Preferences = dataStore.data.first() + return prefs.contains(KEY_CIPHERTEXT) && prefs.contains(KEY_IV) + } + + /** + * Returns the stored (ciphertext, iv) pair, or `null` if either entry is missing. + * Both values are Base64-encoded strings (no padding, [android.util.Base64.NO_WRAP]). + */ + suspend fun load(): Pair? { + val prefs: Preferences = dataStore.data.first() + val ciphertext: String = prefs[KEY_CIPHERTEXT] ?: return null + val iv: String = prefs[KEY_IV] ?: return null + return ciphertext to iv + } + + /** Persists the encrypted credential pair. Overwrites any existing values. */ + suspend fun save(ciphertext: String, iv: String) = dataStore.edit { prefs -> + prefs[KEY_CIPHERTEXT] = ciphertext + prefs[KEY_IV] = iv + } + + /** Removes all stored credentials. */ + suspend fun clear() = dataStore.edit { prefs -> + prefs.remove(KEY_CIPHERTEXT) + prefs.remove(KEY_IV) + } + + companion object { + private const val PREFS_NAME = "notedelight_biometric_prefs" + private val KEY_CIPHERTEXT: Preferences.Key = stringPreferencesKey("ciphertext") + private val KEY_IV: Preferences.Key = stringPreferencesKey("iv") + } +} diff --git a/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.android.kt b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.android.kt new file mode 100644 index 000000000..97673102d --- /dev/null +++ b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.android.kt @@ -0,0 +1,5 @@ +package com.softartdev.notedelight.interactor + +import androidx.fragment.app.FragmentActivity + +actual class BiometricPlatformWrapper(val activity: FragmentActivity) 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 new file mode 100644 index 000000000..0699c0e5d --- /dev/null +++ b/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt @@ -0,0 +1,31 @@ +package com.softartdev.notedelight.interactor + +import kotlinx.coroutines.channels.Channel + +interface BiometricInteractor { + + suspend fun canAuthenticate(): Boolean + + suspend fun hasStoredPassword(): Boolean + + suspend fun encryptAndStorePassword( + password: CharSequence, + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, + ): BiometricResult + + suspend fun decryptStoredPassword( + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, + ): DecryptedPasswordResult + + suspend fun clearStoredPassword() + + companion object { + val disableDialogChannel: Channel by lazy { Channel() } + } +} diff --git a/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.kt b/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.kt new file mode 100644 index 000000000..e8f9e5cc0 --- /dev/null +++ b/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.kt @@ -0,0 +1,9 @@ +package com.softartdev.notedelight.interactor + +/** + * Platform-agnostic wrapper that carries the current host Activity (Android) or nothing + * (iOS/Desktop/Web). Created from a `@Composable` context via `rememberActivityProvider()` + * in `core:ui` and forwarded to [BiometricInteractor] methods that need to show the system + * biometric prompt. + */ +expect class BiometricPlatformWrapper diff --git a/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricResult.kt b/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricResult.kt new file mode 100644 index 000000000..c31dfecc4 --- /dev/null +++ b/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricResult.kt @@ -0,0 +1,16 @@ +package com.softartdev.notedelight.interactor + +sealed interface BiometricResult { + data object Success : BiometricResult + data object Failed : BiometricResult + data object Cancelled : BiometricResult + data object Unavailable : BiometricResult + data class Error(val message: String) : BiometricResult +} + +sealed interface DecryptedPasswordResult { + data class Success(val password: CharSequence) : DecryptedPasswordResult + data object Cancelled : DecryptedPasswordResult + data object Unavailable : DecryptedPasswordResult + data class Failure(val message: String) : DecryptedPasswordResult +} diff --git a/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.ios.kt b/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.ios.kt new file mode 100644 index 000000000..a46fcbce6 --- /dev/null +++ b/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.ios.kt @@ -0,0 +1,3 @@ +package com.softartdev.notedelight.interactor + +actual class BiometricPlatformWrapper diff --git a/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/IosBiometricInteractor.kt b/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/IosBiometricInteractor.kt new file mode 100644 index 000000000..fb3103298 --- /dev/null +++ b/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/IosBiometricInteractor.kt @@ -0,0 +1,255 @@ +@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + +package com.softartdev.notedelight.interactor + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value +import kotlinx.coroutines.suspendCancellableCoroutine +import platform.CoreFoundation.CFDictionaryAddValue +import platform.CoreFoundation.CFDictionaryCreateMutable +import platform.CoreFoundation.CFMutableDictionaryRef +import platform.CoreFoundation.CFRelease +import platform.CoreFoundation.CFTypeRef +import platform.CoreFoundation.CFTypeRefVar +import platform.CoreFoundation.kCFAllocatorDefault +import platform.CoreFoundation.kCFBooleanTrue +import platform.CoreFoundation.kCFTypeDictionaryKeyCallBacks +import platform.CoreFoundation.kCFTypeDictionaryValueCallBacks +import platform.Foundation.CFBridgingRelease +import platform.Foundation.CFBridgingRetain +import platform.Foundation.NSData +import platform.Foundation.NSError +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.create +import platform.Foundation.dataUsingEncoding +import platform.LocalAuthentication.LAContext +import platform.LocalAuthentication.LAErrorAuthenticationFailed +import platform.LocalAuthentication.LAErrorBiometryNotAvailable +import platform.LocalAuthentication.LAErrorBiometryNotEnrolled +import platform.LocalAuthentication.LAErrorPasscodeNotSet +import platform.LocalAuthentication.LAErrorSystemCancel +import platform.LocalAuthentication.LAErrorUserCancel +import platform.LocalAuthentication.LAErrorUserFallback +import platform.LocalAuthentication.LAPolicyDeviceOwnerAuthenticationWithBiometrics +import platform.Security.SecAccessControlCreateWithFlags +import platform.Security.SecAccessControlRef +import platform.Security.SecItemAdd +import platform.Security.SecItemCopyMatching +import platform.Security.SecItemDelete +import platform.Security.errSecInteractionNotAllowed +import platform.Security.errSecItemNotFound +import platform.Security.errSecSuccess +import platform.Security.kSecAccessControlBiometryCurrentSet +import platform.Security.kSecAttrAccessControl +import platform.Security.kSecAttrAccessibleWhenUnlockedThisDeviceOnly +import platform.Security.kSecAttrAccount +import platform.Security.kSecAttrService +import platform.Security.kSecClass +import platform.Security.kSecClassGenericPassword +import platform.Security.kSecReturnData +import platform.Security.kSecUseAuthenticationContext +import platform.Security.kSecUseAuthenticationUI +import platform.Security.kSecUseAuthenticationUIFail +import platform.Security.kSecValueData +import platform.darwin.OSStatus +import kotlin.coroutines.resume + +class IosBiometricInteractor : BiometricInteractor { + + override suspend fun canAuthenticate(): Boolean = LAContext() + .canEvaluatePolicy(LAPolicyDeviceOwnerAuthenticationWithBiometrics, null) + + override suspend fun hasStoredPassword(): Boolean = memScoped { + val service: CFTypeRef? = CFBridgingRetain(SERVICE) + val account: CFTypeRef? = CFBridgingRetain(ACCOUNT) + val query: CFMutableDictionaryRef? = newMutableDict() + CFDictionaryAddValue(query, kSecClass, kSecClassGenericPassword) + CFDictionaryAddValue(query, kSecAttrService, service) + CFDictionaryAddValue(query, kSecAttrAccount, account) + CFDictionaryAddValue(query, kSecUseAuthenticationUI, kSecUseAuthenticationUIFail) + try { + val status: OSStatus = SecItemCopyMatching(query, null) + return@memScoped status == errSecSuccess || status == errSecInteractionNotAllowed + } finally { + CFRelease(query) + CFRelease(service) + CFRelease(account) + } + } + + override suspend fun encryptAndStorePassword( + password: CharSequence, + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, + ): BiometricResult { + clearStoredPassword() + val context = LAContext().apply { + localizedFallbackTitle = "" + localizedCancelTitle = negativeButton + } + val authResult: BiometricResult = evaluatePolicy(context, "$title\n$subtitle") + if (authResult !is BiometricResult.Success) return authResult + val accessControl: SecAccessControlRef = SecAccessControlCreateWithFlags( + allocator = null, + protection = kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + flags = kSecAccessControlBiometryCurrentSet, + error = null, + ) ?: return BiometricResult.Error("Could not create access control") + val passwordData: NSData = NSString.create(string = password.toString()) + .dataUsingEncoding(NSUTF8StringEncoding) + ?: return BiometricResult.Error("Could not encode password") + return memScoped { + val service: CFTypeRef? = CFBridgingRetain(SERVICE) + val account: CFTypeRef? = CFBridgingRetain(ACCOUNT) + val data: CFTypeRef? = CFBridgingRetain(passwordData) + val ctxRef: CFTypeRef? = CFBridgingRetain(context) + val attrs: CFMutableDictionaryRef? = newMutableDict() + CFDictionaryAddValue(attrs, kSecClass, kSecClassGenericPassword) + CFDictionaryAddValue(attrs, kSecAttrService, service) + CFDictionaryAddValue(attrs, kSecAttrAccount, account) + CFDictionaryAddValue(attrs, kSecValueData, data) + CFDictionaryAddValue(attrs, kSecAttrAccessControl, accessControl) + CFDictionaryAddValue(attrs, kSecUseAuthenticationContext, ctxRef) + try { + return@memScoped when (val status: OSStatus = SecItemAdd(attrs, null)) { + errSecSuccess -> BiometricResult.Success + else -> mapKeychainStatus(status) + } + } finally { + CFRelease(attrs) + CFRelease(service) + CFRelease(account) + CFRelease(data) + CFRelease(ctxRef) + } + } + } + + override suspend fun decryptStoredPassword( + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, + ): DecryptedPasswordResult { + if (!hasStoredPassword()) { + return DecryptedPasswordResult.Unavailable + } + val context = LAContext().apply { + localizedReason = "$title\n$subtitle" + localizedFallbackTitle = "" + localizedCancelTitle = negativeButton + } + return memScoped { + val resultRef: CFTypeRefVar = alloc() + val service: CFTypeRef? = CFBridgingRetain(SERVICE) + val account: CFTypeRef? = CFBridgingRetain(ACCOUNT) + val ctxRef: CFTypeRef? = CFBridgingRetain(context) + val query: CFMutableDictionaryRef? = newMutableDict() + CFDictionaryAddValue(query, kSecClass, kSecClassGenericPassword) + CFDictionaryAddValue(query, kSecAttrService, service) + CFDictionaryAddValue(query, kSecAttrAccount, account) + CFDictionaryAddValue(query, kSecReturnData, kCFBooleanTrue) + CFDictionaryAddValue(query, kSecUseAuthenticationContext, ctxRef) + val status: OSStatus = try { + SecItemCopyMatching(query, resultRef.ptr) + } finally { + CFRelease(query) + CFRelease(service) + CFRelease(account) + CFRelease(ctxRef) + } + when (status) { + errSecSuccess -> { + val data = CFBridgingRelease(resultRef.value) as? NSData + val pwd = data?.let { nsData -> + NSString.create(data = nsData, encoding = NSUTF8StringEncoding)?.toString() + } + if (pwd != null) { + DecryptedPasswordResult.Success(pwd) + } else { + DecryptedPasswordResult.Failure("Decoding failed") + } + } + errSecItemNotFound -> { + clearStoredPassword() + DecryptedPasswordResult.Unavailable + } + else -> when (val biometricResult: BiometricResult = mapKeychainStatus(status)) { + BiometricResult.Unavailable -> DecryptedPasswordResult.Unavailable + BiometricResult.Cancelled -> DecryptedPasswordResult.Cancelled + is BiometricResult.Error -> DecryptedPasswordResult.Failure(biometricResult.message) + else -> DecryptedPasswordResult.Failure(biometricResult.toString()) + } + } + } + } + + override suspend fun clearStoredPassword(): Unit = memScoped { + val service: CFTypeRef? = CFBridgingRetain(SERVICE) + val account: CFTypeRef? = CFBridgingRetain(ACCOUNT) + val query: CFMutableDictionaryRef? = newMutableDict() + CFDictionaryAddValue(query, kSecClass, kSecClassGenericPassword) + CFDictionaryAddValue(query, kSecAttrService, service) + CFDictionaryAddValue(query, kSecAttrAccount, account) + try { + SecItemDelete(query) + } finally { + CFRelease(query) + CFRelease(service) + CFRelease(account) + } + } + + private fun newMutableDict(): CFMutableDictionaryRef? = CFDictionaryCreateMutable( + allocator = kCFAllocatorDefault, + capacity = 0, + keyCallBacks = kCFTypeDictionaryKeyCallBacks.ptr, + valueCallBacks = kCFTypeDictionaryValueCallBacks.ptr, + ) + + private suspend fun evaluatePolicy( + context: LAContext, + reason: String + ): BiometricResult = suspendCancellableCoroutine { continuation -> + return@suspendCancellableCoroutine context.evaluatePolicy( + policy = LAPolicyDeviceOwnerAuthenticationWithBiometrics, + localizedReason = reason, + ) { success: Boolean, error: NSError? -> + when { + success -> continuation.resume(BiometricResult.Success) + else -> { + val mapped = when (error?.code) { + LAErrorUserCancel, + LAErrorSystemCancel, + LAErrorUserFallback -> BiometricResult.Cancelled + LAErrorBiometryNotAvailable, + LAErrorBiometryNotEnrolled, + LAErrorPasscodeNotSet -> BiometricResult.Unavailable + LAErrorAuthenticationFailed -> BiometricResult.Failed + else -> BiometricResult.Error( + message = error?.localizedDescription ?: "LAContext error" + ) + } + continuation.resume(mapped) + } + } + } + } + + private fun mapKeychainStatus(status: OSStatus): BiometricResult = when (status) { + errSecItemNotFound -> BiometricResult.Unavailable + else -> BiometricResult.Error("Keychain status: $status") + } + + companion object { + private const val SERVICE = "com.softartdev.notedelight.biometric" + private const val ACCOUNT = "db_password" + } +} diff --git a/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.jvm.kt b/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.jvm.kt new file mode 100644 index 000000000..a46fcbce6 --- /dev/null +++ b/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.jvm.kt @@ -0,0 +1,3 @@ +package com.softartdev.notedelight.interactor + +actual class BiometricPlatformWrapper diff --git a/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/JvmBiometricInteractor.kt b/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/JvmBiometricInteractor.kt new file mode 100644 index 000000000..c5887ce04 --- /dev/null +++ b/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/JvmBiometricInteractor.kt @@ -0,0 +1,25 @@ +package com.softartdev.notedelight.interactor + +class JvmBiometricInteractor : BiometricInteractor { + + override suspend fun canAuthenticate(): Boolean = false + + override suspend fun hasStoredPassword(): Boolean = false + + override suspend fun encryptAndStorePassword( + password: CharSequence, + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, + ): BiometricResult = BiometricResult.Unavailable + + override suspend fun decryptStoredPassword( + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, + ): DecryptedPasswordResult = DecryptedPasswordResult.Unavailable + + override suspend fun clearStoredPassword() = Unit +} diff --git a/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.wasmJs.kt b/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.wasmJs.kt new file mode 100644 index 000000000..a46fcbce6 --- /dev/null +++ b/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.wasmJs.kt @@ -0,0 +1,3 @@ +package com.softartdev.notedelight.interactor + +actual class BiometricPlatformWrapper diff --git a/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/WebBiometricInteractor.kt b/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/WebBiometricInteractor.kt new file mode 100644 index 000000000..73ff49076 --- /dev/null +++ b/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/WebBiometricInteractor.kt @@ -0,0 +1,25 @@ +package com.softartdev.notedelight.interactor + +class WebBiometricInteractor : BiometricInteractor { + + override suspend fun canAuthenticate(): Boolean = false + + override suspend fun hasStoredPassword(): Boolean = false + + override suspend fun encryptAndStorePassword( + password: CharSequence, + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, + ): BiometricResult = BiometricResult.Unavailable + + override suspend fun decryptStoredPassword( + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, + ): DecryptedPasswordResult = DecryptedPasswordResult.Unavailable + + override suspend fun clearStoredPassword() = Unit +} diff --git a/feature/biometric/presentation/README.md b/feature/biometric/presentation/README.md new file mode 100644 index 000000000..91e6ab9fe --- /dev/null +++ b/feature/biometric/presentation/README.md @@ -0,0 +1,67 @@ +# feature:biometric:presentation + +Biometric enroll ViewModel — handles password verification and biometric key enrollment. + +## Overview + +Contains `BiometricEnrollViewModel` and its state/action types. The ViewModel is invoked from the `BiometricEnrollDialog` composable in `core:ui`. + +## Components + +### `BiometricEnrollViewModel` + +MVI ViewModel following the Action Interface Pattern: + +```kotlin +class BiometricEnrollViewModel( + checkPasswordUseCase: CheckPasswordUseCase, + biometricInteractor: BiometricInteractor, + snackbarInteractor: SnackbarInteractor, + router: Router, + coroutineDispatchers: CoroutineDispatchers, +) : ViewModel() +``` + +Flow: +1. User types the current database password. +2. `OnEnrollClick` — validates via `CheckPasswordUseCase`. +3. On correct password → calls `BiometricInteractor.encryptAndStorePassword()` — shows the system biometric prompt. +4. On `BiometricResult.Success` → `router.popBackStack()`. +5. On any other result → shows a snackbar with the error. + +### `BiometricEnrollResult` + +```kotlin +data class BiometricEnrollResult( + val loading: Boolean, + val fieldLabel: FieldLabel, + val password: String, + val isPasswordVisible: Boolean, + val isError: Boolean, +) +``` + +### `BiometricEnrollAction` + +```kotlin +sealed interface BiometricEnrollAction { + data object Cancel + data class OnEditPassword(val password: String) + data object TogglePasswordVisibility + data class OnEnrollClick(val title: String, val subtitle: String, val negativeButton: String) +} +``` + +## Dependencies + +- `feature:biometric:domain` — `BiometricInteractor`, `BiometricResult` +- `core:domain` — `CheckPasswordUseCase`, `CoroutineDispatchers`, `CountingIdlingRes` +- `core:presentation` — `SnackbarInteractor`, `Router`, `FieldLabel` +- `androidx.lifecycle:lifecycle-viewmodel` (multiplatform) +- `kermit` logging + +## Testing + +```bash +./gradlew :feature:biometric:presentation:androidHostTest +``` diff --git a/feature/biometric/presentation/build.gradle.kts b/feature/biometric/presentation/build.gradle.kts new file mode 100644 index 000000000..ad07269e1 --- /dev/null +++ b/feature/biometric/presentation/build.gradle.kts @@ -0,0 +1,61 @@ +@file:OptIn(ExperimentalWasmDsl::class) + +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.gradle.convention) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) +} + +kotlin { + jvmToolchain(libs.versions.jdk.get().toInt()) + jvm() + android { + namespace = "com.softartdev.notedelight.feature.biometric.presentation" + compileSdk = libs.versions.compileSdk.get().toInt() + minSdk = libs.versions.minSdk.get().toInt() + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(libs.versions.jdk.get())) + } + withHostTest { } + } + iosArm64() + iosSimulatorArm64() + wasmJs { + browser() + } + sourceSets.forEach { + it.dependencies { + implementation(project.dependencies.enforcedPlatform(libs.coroutines.bom)) + } + } + sourceSets { + commonMain.dependencies { + implementation(projects.core.domain) + implementation(projects.feature.biometric.domain) + implementation(projects.core.presentation) + implementation(libs.coroutines.core) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.kermit) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.coroutines.test) + implementation(libs.turbine) + } + val androidHostTest by getting { + dependencies { + implementation(kotlin("test-junit")) + implementation(libs.bundles.mockito) + implementation(libs.androidx.arch.core.testing) + } + } + } + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") +} + +dependencies { + coreLibraryDesugaring(libs.desugar) +} 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 000000000..81b082232 --- /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.interactor.BiometricInteractor +import com.softartdev.notedelight.navigation.Router +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) { + BiometricInteractor.disableDialogChannel.send(true) + } + router.popBackStack() + } + + fun doNotDisableBiometricAndNavBack() = viewModelScope.launch { + withContext(coroutineDispatchers.io) { + BiometricInteractor.disableDialogChannel.send(false) + } + router.popBackStack() + } +} diff --git a/feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollResult.kt b/feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollResult.kt new file mode 100644 index 000000000..50c20ada3 --- /dev/null +++ b/feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollResult.kt @@ -0,0 +1,29 @@ +package com.softartdev.notedelight.presentation.settings.security.biometric + +import com.softartdev.notedelight.interactor.BiometricPlatformWrapper +import com.softartdev.notedelight.presentation.settings.security.FieldLabel + +data class BiometricEnrollResult( + val loading: Boolean = false, + val fieldLabel: FieldLabel = FieldLabel.ENTER_PASSWORD, + val password: String = "", + val isPasswordVisible: Boolean = false, + val isError: Boolean = false, +) { + fun showLoading(): BiometricEnrollResult = copy(loading = true) + fun hideLoading(): BiometricEnrollResult = copy(loading = false) + fun showError(): BiometricEnrollResult = copy(isError = true) + fun togglePasswordVisibility(): BiometricEnrollResult = copy(isPasswordVisible = !isPasswordVisible) +} + +sealed interface BiometricEnrollAction { + data object Cancel : BiometricEnrollAction + data class OnEditPassword(val password: String) : BiometricEnrollAction + data object TogglePasswordVisibility : BiometricEnrollAction + data class OnEnrollClick( + val title: String, + val subtitle: String, + val negativeButton: String, + val biometricPlatformWrapper: BiometricPlatformWrapper + ) : BiometricEnrollAction +} diff --git a/feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt b/feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt new file mode 100644 index 000000000..4b53b9f2d --- /dev/null +++ b/feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt @@ -0,0 +1,111 @@ +package com.softartdev.notedelight.presentation.settings.security.biometric + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import com.softartdev.notedelight.interactor.BiometricInteractor +import com.softartdev.notedelight.interactor.BiometricPlatformWrapper +import com.softartdev.notedelight.interactor.BiometricResult +import com.softartdev.notedelight.interactor.SnackbarInteractor +import com.softartdev.notedelight.interactor.SnackbarMessage +import com.softartdev.notedelight.navigation.Router +import com.softartdev.notedelight.presentation.settings.security.FieldLabel +import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase +import com.softartdev.notedelight.util.CoroutineDispatchers +import com.softartdev.notedelight.util.CountingIdlingRes +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class BiometricEnrollViewModel( + private val checkPasswordUseCase: CheckPasswordUseCase, + private val biometricInteractor: BiometricInteractor, + private val snackbarInteractor: SnackbarInteractor, + private val router: Router, + private val coroutineDispatchers: CoroutineDispatchers, +) : ViewModel() { + private val logger = Logger.withTag(this@BiometricEnrollViewModel::class.simpleName.toString()) + + private val mutableStateFlow: MutableStateFlow = + MutableStateFlow(BiometricEnrollResult()) + val stateFlow: StateFlow = mutableStateFlow + + fun onAction(action: BiometricEnrollAction) = when (action) { + is BiometricEnrollAction.Cancel -> cancel() + is BiometricEnrollAction.OnEditPassword -> onEditPassword(action.password) + is BiometricEnrollAction.TogglePasswordVisibility -> togglePasswordVisibility() + is BiometricEnrollAction.OnEnrollClick -> enroll( + title = action.title, + subtitle = action.subtitle, + negativeButton = action.negativeButton, + biometricPlatformWrapper = action.biometricPlatformWrapper, + ) + } + + private fun onEditPassword(password: String) = mutableStateFlow.update { result -> + return@update result.copy( + isError = false, + fieldLabel = FieldLabel.ENTER_PASSWORD, + password = password + ) + } + + private fun togglePasswordVisibility() = viewModelScope.launch { + mutableStateFlow.update(BiometricEnrollResult::togglePasswordVisibility) + } + + private fun enroll( + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, + ) = viewModelScope.launch(context = coroutineDispatchers.io) { + CountingIdlingRes.increment() + mutableStateFlow.update(BiometricEnrollResult::showLoading) + try { + val password: String = mutableStateFlow.value.password + when { + password.isEmpty() -> { + mutableStateFlow.update { it.copy(fieldLabel = FieldLabel.EMPTY_PASSWORD) } + mutableStateFlow.update(BiometricEnrollResult::showError) + } + checkPasswordUseCase(password) -> { + val result: BiometricResult = biometricInteractor.encryptAndStorePassword( + password = password, + title = title, + subtitle = subtitle, + negativeButton = negativeButton, + biometricPlatformWrapper = biometricPlatformWrapper, + ) + when (result) { + is BiometricResult.Success -> withContext(coroutineDispatchers.main) { + router.popBackStack() + } + else -> { + val resultMessage: String = when (result) { + is BiometricResult.Error -> result.message + else -> result.toString() + } + logger.e { resultMessage } + snackbarInteractor.showMessage(SnackbarMessage.Simple(resultMessage)) + } + } + } + else -> { + mutableStateFlow.update { it.copy(fieldLabel = FieldLabel.INCORRECT_PASSWORD) } + mutableStateFlow.update(BiometricEnrollResult::showError) + } + } + } catch (e: Throwable) { + logger.e(e) { "Error enrolling biometric" } + e.message?.let { snackbarInteractor.showMessage(SnackbarMessage.Simple(it)) } + } finally { + mutableStateFlow.update(BiometricEnrollResult::hideLoading) + CountingIdlingRes.decrement() + } + } + + private fun cancel() = viewModelScope.launch { router.popBackStack() } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ceac655ad..aac95aa52 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,8 @@ composeMaterial3 = "1.9.0" composeMaterialIconsExtended = "1.7.3" composeMaterialAdaptive = "1.2.0" androidxAppcompat = "1.7.1" +androidxBiometric = "1.1.0" +androidxDatastore = "1.2.1" androidxViewModel = "2.10.0" androidxNavigation = "2.9.2" androidxNavigationEvent = "1.0.1" @@ -120,6 +122,8 @@ compose-adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:a compose-adaptive-navigation = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation", version.ref = "composeMaterialAdaptive" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppcompat" } +androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "androidxBiometric" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidxDatastore" } androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } androidx-navigationevent-compose = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "androidxNavigationEvent" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivityCompose" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 8881301c3..4f181c267 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,8 @@ include(":core:data:db-sqldelight") include(":feature:file-explorer:data") include(":feature:backup:domain") include(":feature:backup:ui") +include(":feature:biometric:domain") +include(":feature:biometric:presentation") include(":feature:console:domain") include(":feature:console:presentation") include(":feature:console:ui")