From 2feb74c75333775fa142e287f2a2ccc05e529b5b Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Mon, 27 Apr 2026 16:14:06 +0400 Subject: [PATCH] settings: add biometric preference flow --- .../adaptive/AdaptiveInteractorTest.kt | 9 ++- .../settings/SettingsViewModelTest.kt | 10 ++- .../interactor/BiometricInteractor.android.kt | 40 +++++++++++ .../interactor/BiometricInteractor.kt | 12 ++++ .../presentation/settings/SettingsResult.kt | 5 ++ .../settings/SettingsViewModel.kt | 56 +++++++++++++++ .../interactor/BiometricInteractor.ios.kt | 27 +++++++ .../interactor/BiometricInteractor.jvm.kt | 30 ++++++++ .../interactor/BiometricInteractor.wasmJs.kt | 32 +++++++++ .../notedelight/di/uiModules.android.kt | 2 + .../composeResources/values-ru/strings.xml | 4 ++ .../composeResources/values/strings.xml | 4 ++ .../settings/detail/SettingsDetailScreen.kt | 70 +++++++++++++++++++ .../notedelight/di/uiModules.ios.kt | 2 + .../notedelight/di/uiModules.jvm.kt | 2 + .../notedelight/di/uiModules.wasmJs.kt | 2 + 16 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt create mode 100644 core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt create mode 100644 core/presentation/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt create mode 100644 core/presentation/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt create mode 100644 core/presentation/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt 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..a55b38648 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,8 @@ 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.BiometricCapability +import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.LocaleInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.model.SettingsCategory @@ -30,6 +32,7 @@ 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.crypt.CheckPasswordUseCase import com.softartdev.notedelight.usecase.settings.AppVersionUseCase import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase import com.softartdev.notedelight.usecase.settings.ImportDatabaseUseCase @@ -67,6 +70,7 @@ class AdaptiveInteractorTest { private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java) private val mockLocaleInteractor = Mockito.mock(LocaleInteractor::class.java) private val mockAppVersionUseCase = Mockito.mock(AppVersionUseCase::class.java) + private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java) private val checkSqlCipherVersionUseCase = CheckSqlCipherVersionUseCase(mockSafeRepo) private val revealFileListUseCase = RevealFileListUseCase() private val adaptiveInteractor = AdaptiveInteractor() @@ -110,6 +114,7 @@ class AdaptiveInteractorTest { settingsViewModel = SettingsViewModel( safeRepo = mockSafeRepo, checkSqlCipherVersionUseCase = checkSqlCipherVersionUseCase, + checkPasswordUseCase = CheckPasswordUseCase(mockSafeRepo), exportDatabaseUseCase = ExportDatabaseUseCase(mockSafeRepo), importDatabaseUseCase = ImportDatabaseUseCase(mockSafeRepo), appVersionUseCase = mockAppVersionUseCase, @@ -117,6 +122,7 @@ class AdaptiveInteractorTest { router = mockRouter, revealFileListUseCase = revealFileListUseCase, localeInteractor = mockLocaleInteractor, + biometricInteractor = mockBiometricInteractor, adaptiveInteractor = adaptiveInteractor, coroutineDispatchers = coroutineDispatchers, ) @@ -125,11 +131,12 @@ class AdaptiveInteractorTest { Mockito.`when`(mockNoteDAO.count()).thenReturn(0) Mockito.`when`(mockCreateNoteUseCase.invoke()).thenReturn(id) Mockito.`when`(mockNoteDAO.load(id)).thenReturn(note) + Mockito.`when`(mockBiometricInteractor.capability()).thenReturn(BiometricCapability(false, false)) } @After fun tearDown() = runTest { - Mockito.reset(mockSafeRepo, mockRouter, mockNoteDAO, mockCreateNoteUseCase, mockDeleteNoteUseCase, mockSnackbarInteractor, mockLocaleInteractor, mockAppVersionUseCase) + Mockito.reset(mockSafeRepo, mockRouter, mockNoteDAO, mockCreateNoteUseCase, mockDeleteNoteUseCase, mockSnackbarInteractor, mockLocaleInteractor, mockAppVersionUseCase, mockBiometricInteractor) Logger.setLogWriters() } 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..1386c6a41 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,8 @@ 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.BiometricCapability import com.softartdev.notedelight.interactor.LocaleInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarMessage @@ -16,6 +18,7 @@ import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.presentation.MainDispatcherRule import com.softartdev.notedelight.repository.SafeRepo import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase +import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase import com.softartdev.notedelight.usecase.settings.AppVersionUseCase import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase import com.softartdev.notedelight.usecase.settings.ImportDatabaseUseCase @@ -45,11 +48,13 @@ class SettingsViewModelTest { private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java) private val mockLocaleInteractor = Mockito.mock(LocaleInteractor::class.java) private val mockAppVersionUseCase = Mockito.mock(AppVersionUseCase::class.java) + private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java) private val adaptiveInteractor = AdaptiveInteractor() private val coroutineDispatchers = CoroutineDispatchersStub(mainDispatcherRule.testDispatcher.scheduler) private val settingsViewModel = SettingsViewModel( safeRepo = mockSafeRepo, checkSqlCipherVersionUseCase = checkSqlCipherVersionUseCase, + checkPasswordUseCase = CheckPasswordUseCase(mockSafeRepo), exportDatabaseUseCase = ExportDatabaseUseCase(mockSafeRepo), importDatabaseUseCase = ImportDatabaseUseCase(mockSafeRepo), appVersionUseCase = mockAppVersionUseCase, @@ -57,13 +62,14 @@ class SettingsViewModelTest { router = mockRouter, revealFileListUseCase = RevealFileListUseCase(), localeInteractor = mockLocaleInteractor, + biometricInteractor = mockBiometricInteractor, adaptiveInteractor = adaptiveInteractor, coroutineDispatchers = coroutineDispatchers, ) @After fun tearDown() = runTest { - Mockito.reset(mockSafeRepo, mockSnackbarInteractor, mockRouter, mockAppVersionUseCase) + Mockito.reset(mockSafeRepo, mockSnackbarInteractor, mockRouter, mockAppVersionUseCase, mockBiometricInteractor) } @Test @@ -83,6 +89,7 @@ class SettingsViewModelTest { fun refreshUpdatesSwitches() = runTest { Mockito.`when`(mockSafeRepo.databaseState).thenReturn(ENCRYPTED) Mockito.`when`(mockLocaleInteractor.languageEnum).thenReturn(LanguageEnum.ENGLISH) + Mockito.`when`(mockBiometricInteractor.capability()).thenReturn(BiometricCapability(false, false)) settingsViewModel.stateFlow.test { assertFalse(awaitItem().loading) settingsViewModel.onAction(SettingsAction.Refresh) @@ -100,6 +107,7 @@ class SettingsViewModelTest { val platformSQLiteState = if (encryption) ENCRYPTED else UNENCRYPTED Mockito.`when`(mockSafeRepo.databaseState).thenReturn(platformSQLiteState) Mockito.`when`(mockLocaleInteractor.languageEnum).thenReturn(LanguageEnum.ENGLISH) + Mockito.`when`(mockBiometricInteractor.capability()).thenReturn(BiometricCapability(false, false)) settingsViewModel.stateFlow.test { assertFalse(awaitItem().loading) settingsViewModel.updateSwitches() diff --git a/core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt b/core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt new file mode 100644 index 000000000..7fe390a58 --- /dev/null +++ b/core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt @@ -0,0 +1,40 @@ +package com.softartdev.notedelight.interactor + +import android.app.KeyguardManager +import android.content.Context +import android.hardware.fingerprint.FingerprintManager +import android.os.Build + +actual class BiometricInteractor(private val context: Context) { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + actual var biometricEnabled: Boolean + get() = prefs.getBoolean(KEY_BIOMETRIC_ENABLED, false) + set(value) { + prefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, value).apply() + } + + actual var biometricConfirmed: Boolean + get() = prefs.getBoolean(KEY_BIOMETRIC_CONFIRMED, false) + set(value) { + prefs.edit().putBoolean(KEY_BIOMETRIC_CONFIRMED, value).apply() + } + + actual fun capability(): BiometricCapability { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return BiometricCapability(available = false, enrolled = false) + } + val fingerprintManager = context.getSystemService(Context.FINGERPRINT_SERVICE) as? FingerprintManager + ?: return BiometricCapability(available = false, enrolled = false) + val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager + val available = fingerprintManager.isHardwareDetected && (keyguardManager?.isKeyguardSecure == true) + val enrolled = available && fingerprintManager.hasEnrolledFingerprints() + return BiometricCapability(available = available, enrolled = enrolled) + } + + private companion object { + private const val PREFS_NAME = "notedelight_settings" + private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled" + private const val KEY_BIOMETRIC_CONFIRMED = "biometric_confirmed" + } +} diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt new file mode 100644 index 000000000..c671b6cba --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt @@ -0,0 +1,12 @@ +package com.softartdev.notedelight.interactor + +data class BiometricCapability( + val available: Boolean, + val enrolled: Boolean, +) + +expect class BiometricInteractor { + var biometricEnabled: Boolean + var biometricConfirmed: Boolean + fun capability(): BiometricCapability +} 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..057499cb3 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,10 @@ 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 biometricEnrolled: Boolean = false, + val biometricNeedsPasswordConfirmation: Boolean = true, val fileListVisible: Boolean = false, val language: LanguageEnum = LanguageEnum.ENGLISH, val appVersion: String? = null, @@ -23,6 +27,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, val password: String? = null) : 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..b43c8245d 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 @@ -13,6 +14,7 @@ import com.softartdev.notedelight.navigation.AppNavGraph import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.repository.SafeRepo import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase +import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase import com.softartdev.notedelight.usecase.settings.AppVersionUseCase import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase import com.softartdev.notedelight.usecase.settings.ImportDatabaseUseCase @@ -29,6 +31,7 @@ import kotlinx.coroutines.withContext class SettingsViewModel( private val safeRepo: SafeRepo, private val checkSqlCipherVersionUseCase: CheckSqlCipherVersionUseCase, + private val checkPasswordUseCase: CheckPasswordUseCase, private val exportDatabaseUseCase: ExportDatabaseUseCase, private val importDatabaseUseCase: ImportDatabaseUseCase, private val appVersionUseCase: AppVersionUseCase, @@ -36,6 +39,7 @@ class SettingsViewModel( private val router: Router, private val revealFileListUseCase: RevealFileListUseCase, private val localeInteractor: LocaleInteractor, + private val biometricInteractor: BiometricInteractor, private val adaptiveInteractor: AdaptiveInteractor, private val coroutineDispatchers: CoroutineDispatchers, ) : ViewModel() { @@ -62,6 +66,7 @@ class SettingsViewModel( is SettingsAction.ChangeTheme -> changeTheme() is SettingsAction.ChangeLanguage -> changeLanguage() is SettingsAction.ChangeEncryption -> changeEncryption(action.checked) + is SettingsAction.ChangeBiometric -> changeBiometric(action.checked, action.password) is SettingsAction.ChangePassword -> changePassword() is SettingsAction.ShowCipherVersion -> showCipherVersion() is SettingsAction.ShowDatabasePath -> showDatabasePath() @@ -76,8 +81,20 @@ class SettingsViewModel( mutableStateFlow.update(SettingsResult::showLoading) try { mutableStateFlow.update { result -> + val biometricCapability = biometricInteractor.capability() + val biometricEnabled = biometricInteractor.biometricEnabled + if (biometricEnabled && (!biometricCapability.available || !biometricCapability.enrolled)) { + biometricInteractor.biometricEnabled = false + snackbarInteractor.showMessage( + SnackbarMessage.Simple("Biometrics were disabled because device capability changed.") + ) + } result.copy( encryption = dbIsEncrypted, + biometricEnabled = biometricInteractor.biometricEnabled, + biometricAvailable = biometricCapability.available, + biometricEnrolled = biometricCapability.enrolled, + biometricNeedsPasswordConfirmation = !biometricInteractor.biometricConfirmed, language = localeInteractor.languageEnum, appVersion = appVersionUseCase.invoke() ) @@ -150,6 +167,45 @@ class SettingsViewModel( } } + private fun changeBiometric(checked: Boolean, password: String?) = viewModelScope.launch { + CountingIdlingRes.increment() + mutableStateFlow.update(SettingsResult::showLoading) + try { + val capability = biometricInteractor.capability() + when { + !checked -> biometricInteractor.biometricEnabled = false + !capability.available || !capability.enrolled -> { + biometricInteractor.biometricEnabled = false + snackbarInteractor.showMessage(SnackbarMessage.Simple("Biometrics are not available on this device.")) + } + !dbIsEncrypted -> { + biometricInteractor.biometricEnabled = false + snackbarInteractor.showMessage( + SnackbarMessage.Simple("Enable password protection before turning on biometrics.") + ) + } + !biometricInteractor.biometricConfirmed -> { + if (password.isNullOrEmpty()) { + snackbarInteractor.showMessage(SnackbarMessage.Simple("Confirm your password to enable biometrics.")) + } else if (checkPasswordUseCase(password)) { + biometricInteractor.biometricConfirmed = true + biometricInteractor.biometricEnabled = true + } else { + biometricInteractor.biometricEnabled = false + snackbarInteractor.showMessage(SnackbarMessage.Simple("Incorrect password. Biometrics remain off.")) + } + } + else -> biometricInteractor.biometricEnabled = true + } + updateSwitches() + } catch (e: Throwable) { + handleError(e) { "error changing biometrics" } + } finally { + mutableStateFlow.update(SettingsResult::hideLoading) + CountingIdlingRes.decrement() + } + } + private fun showCipherVersion() = viewModelScope.launch { CountingIdlingRes.increment() mutableStateFlow.update(SettingsResult::showLoading) diff --git a/core/presentation/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt b/core/presentation/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt new file mode 100644 index 000000000..ed4d4f45f --- /dev/null +++ b/core/presentation/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt @@ -0,0 +1,27 @@ +package com.softartdev.notedelight.interactor + +import platform.Foundation.NSUserDefaults + +actual class BiometricInteractor { + actual var biometricEnabled: Boolean + get() = NSUserDefaults.standardUserDefaults.boolForKey(KEY_BIOMETRIC_ENABLED) + set(value) { + NSUserDefaults.standardUserDefaults.setBool(value, KEY_BIOMETRIC_ENABLED) + } + + actual var biometricConfirmed: Boolean + get() = NSUserDefaults.standardUserDefaults.boolForKey(KEY_BIOMETRIC_CONFIRMED) + set(value) { + NSUserDefaults.standardUserDefaults.setBool(value, KEY_BIOMETRIC_CONFIRMED) + } + + actual fun capability(): BiometricCapability = BiometricCapability( + available = false, + enrolled = false, + ) + + private companion object { + private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled" + private const val KEY_BIOMETRIC_CONFIRMED = "biometric_confirmed" + } +} diff --git a/core/presentation/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt b/core/presentation/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt new file mode 100644 index 000000000..ddf85939c --- /dev/null +++ b/core/presentation/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt @@ -0,0 +1,30 @@ +package com.softartdev.notedelight.interactor + +import java.util.prefs.Preferences + +actual class BiometricInteractor { + private val preferences = Preferences.userRoot().node(PREFS_NODE) + + actual var biometricEnabled: Boolean + get() = preferences.getBoolean(KEY_BIOMETRIC_ENABLED, false) + set(value) { + preferences.putBoolean(KEY_BIOMETRIC_ENABLED, value) + } + + actual var biometricConfirmed: Boolean + get() = preferences.getBoolean(KEY_BIOMETRIC_CONFIRMED, false) + set(value) { + preferences.putBoolean(KEY_BIOMETRIC_CONFIRMED, value) + } + + actual fun capability(): BiometricCapability = BiometricCapability( + available = false, + enrolled = false, + ) + + private companion object { + private const val PREFS_NODE = "com.softartdev.notedelight.settings" + private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled" + private const val KEY_BIOMETRIC_CONFIRMED = "biometric_confirmed" + } +} diff --git a/core/presentation/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt b/core/presentation/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt new file mode 100644 index 000000000..c7b49d179 --- /dev/null +++ b/core/presentation/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt @@ -0,0 +1,32 @@ +package com.softartdev.notedelight.interactor + +import kotlinx.browser.window + +actual class BiometricInteractor { + actual var biometricEnabled: Boolean + get() = localStorageItem(KEY_BIOMETRIC_ENABLED) == TRUE_VALUE + set(value) { + setLocalStorageItem(KEY_BIOMETRIC_ENABLED, value.toString()) + } + + actual var biometricConfirmed: Boolean + get() = localStorageItem(KEY_BIOMETRIC_CONFIRMED) == TRUE_VALUE + set(value) { + setLocalStorageItem(KEY_BIOMETRIC_CONFIRMED, value.toString()) + } + + actual fun capability(): BiometricCapability = BiometricCapability( + available = false, + enrolled = false, + ) + + private companion object { + private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled" + private const val KEY_BIOMETRIC_CONFIRMED = "biometric_confirmed" + private const val TRUE_VALUE = "true" + } +} + +private fun localStorageItem(key: String): String? = window.localStorage.getItem(key) + +private fun setLocalStorageItem(key: String, value: String) = window.localStorage.setItem(key, value) 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..d858274e4 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,6 +1,7 @@ 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.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarInteractorImpl @@ -12,4 +13,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) + singleOf(::BiometricInteractor) } \ No newline at end of file diff --git a/core/ui/src/commonMain/composeResources/values-ru/strings.xml b/core/ui/src/commonMain/composeResources/values-ru/strings.xml index a75167cb1..a624b5907 100644 --- a/core/ui/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-ru/strings.xml @@ -19,6 +19,9 @@ Безопасность Включить шифрование Установить пароль + Включить биометрию + Биометрия недоступна на этом устройстве + Добавьте биометрию, чтобы использовать эту опцию Проверить версию SQLCipher Исходный код Версия @@ -41,6 +44,7 @@ Ошибка Не удалось загрузить заметку, попробуйте еще раз! Отмена + Ок Повторить Лицензии открытого ПО Настройки diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index d8fa04fa2..e1e2884bc 100644 --- a/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/core/ui/src/commonMain/composeResources/values/strings.xml @@ -19,6 +19,9 @@ Security Enable encryption Set password + Enable biometrics + Biometrics are unavailable on this device + Enroll a biometric to use this option Check SQLCipher version Source code Version @@ -41,6 +44,7 @@ Error There was a problem loading the note, please try again! Cancel + OK Retry Open source licenses Settings 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..b20df9d36 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 @@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Password import androidx.compose.material.icons.filled.Storage import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -31,6 +32,7 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshState @@ -61,6 +63,7 @@ import com.softartdev.notedelight.presentation.settings.SettingsResult import com.softartdev.notedelight.presentation.settings.SettingsViewModel import com.softartdev.notedelight.repository.SafeRepo import com.softartdev.notedelight.ui.NavBackHandler +import com.softartdev.notedelight.ui.PasswordField import com.softartdev.notedelight.ui.SettingsDetailPanePlaceholder import com.softartdev.notedelight.ui.icon.FileLock import com.softartdev.notedelight.util.ENABLE_ENCRYPTION_SWITCH_TAG @@ -75,7 +78,10 @@ import com.softartdev.theme.material3.ThemePreferenceItem 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_subtitle_biometrics_not_enrolled +import notedelight.core.ui.generated.resources.pref_subtitle_biometrics_unavailable import notedelight.core.ui.generated.resources.pref_title_check_cipher_version +import notedelight.core.ui.generated.resources.pref_title_enable_biometrics 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 @@ -84,6 +90,10 @@ import notedelight.core.ui.generated.resources.pref_title_set_password import notedelight.core.ui.generated.resources.pref_title_show_db_path import notedelight.core.ui.generated.resources.pref_title_source_code import notedelight.core.ui.generated.resources.pref_title_version +import notedelight.core.ui.generated.resources.cancel +import notedelight.core.ui.generated.resources.confirm_password_dialog_title +import notedelight.core.ui.generated.resources.enter_password +import notedelight.core.ui.generated.resources.ok import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.testTag as semanticsTestTag @@ -181,6 +191,8 @@ private fun AppearancePreferences(result: SettingsResult, onAction: (SettingsAct @Composable private fun SecurityPreferences(result: SettingsResult, onAction: (SettingsAction) -> Unit) { + val showBiometricPasswordDialog = remember { mutableStateOf(false) } + val biometricPassword = remember { mutableStateOf("") } val enableEncryptionPrefTitle = stringResource(Res.string.pref_title_enable_encryption) Preference( modifier = Modifier.semantics { @@ -203,11 +215,69 @@ private fun SecurityPreferences(result: SettingsResult, onAction: (SettingsActio vector = Icons.Default.Password, onClick = { onAction(SettingsAction.ChangePassword) } ) + Preference( + title = stringResource(Res.string.pref_title_enable_biometrics), + vector = Icons.Default.Password, + onClick = { + when { + result.biometricEnabled -> onAction(SettingsAction.ChangeBiometric(checked = false)) + result.biometricNeedsPasswordConfirmation -> showBiometricPasswordDialog.value = true + else -> onAction(SettingsAction.ChangeBiometric(checked = true)) + } + }, + secondaryText = when { + !result.biometricAvailable -> ({ Text(stringResource(Res.string.pref_subtitle_biometrics_unavailable)) }) + !result.biometricEnrolled -> ({ Text(stringResource(Res.string.pref_subtitle_biometrics_not_enrolled)) }) + else -> null + } + ) { + Switch( + checked = result.biometricEnabled, + enabled = result.biometricAvailable && result.biometricEnrolled, + onCheckedChange = { checked -> + if (checked && result.biometricNeedsPasswordConfirmation) { + showBiometricPasswordDialog.value = true + } else { + onAction(SettingsAction.ChangeBiometric(checked = checked)) + } + } + ) + } Preference( title = stringResource(Res.string.pref_title_check_cipher_version), vector = Icons.Filled.FileLock, onClick = { onAction(SettingsAction.ShowCipherVersion) } ) + if (showBiometricPasswordDialog.value) { + AlertDialog( + onDismissRequest = { showBiometricPasswordDialog.value = false }, + title = { Text(stringResource(Res.string.confirm_password_dialog_title)) }, + text = { + PasswordField( + passwordState = biometricPassword, + label = stringResource(Res.string.enter_password), + contentDescription = stringResource(Res.string.enter_password), + ) + }, + confirmButton = { + TextButton(onClick = { + onAction(SettingsAction.ChangeBiometric(checked = true, password = biometricPassword.value)) + showBiometricPasswordDialog.value = false + biometricPassword.value = "" + }) { + Text(stringResource(Res.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { + showBiometricPasswordDialog.value = false + biometricPassword.value = "" + }) { + Text(stringResource(Res.string.cancel)) + } + } + ) + } } @Composable 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..eb5818960 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,6 +1,7 @@ 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.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarInteractorImpl @@ -12,4 +13,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) + singleOf(::BiometricInteractor) } 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..eb5818960 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,6 +1,7 @@ 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.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarInteractorImpl @@ -12,4 +13,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) + singleOf(::BiometricInteractor) } 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..eb5818960 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,6 +1,7 @@ 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.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarInteractorImpl @@ -12,4 +13,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) + singleOf(::BiometricInteractor) }