From facccbfe32bab9bff50a73b5643028b1ab895281 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 22:01:40 +0000 Subject: [PATCH 01/13] feat: add biometric authentication for SignIn and Settings Adds biometric sign-in (Touch ID / Face ID on iOS, BiometricPrompt on Android) for unlocking the SQLCipher-encrypted database. Users enable it from Settings; on next launch the SignIn screen offers a "Use biometric" button that retrieves the password from OS-secured storage after a successful biometric prompt. Architecture: - Common: expect class BiometricInteractor with BiometricResult / DecryptedPasswordResult sealed types (mirrors LocaleInteractor's expect/actual pattern in core/presentation). - Android: BiometricPrompt + Android Keystore. Password is encrypted with an AES-GCM key marked setUserAuthenticationRequired(true) and setInvalidatedByBiometricEnrollment(true), then stored in plain SharedPreferences. Cipher is unlocked via BiometricPrompt.CryptoObject so decryption literally requires a fresh biometric scan. MainActivity attaches itself to a small BiometricActivityHolder for prompt access. - iOS: LAContext + Keychain Services with kSecAccessControlBiometryCurrentSet on a generic-password Keychain item. Item invalidates on biometric re-enrollment. - JVM/Web get no-op stubs so the multiplatform build stays green. Settings flow: a ChangeBiometric(true) action navigates to the new BiometricEnrollDialog where the user re-types the password, then encryptAndStorePassword runs the biometric prompt. Disabling clears the stored ciphertext. Whenever the DB password changes via Change/Enter/ Confirm ViewModels, any stored biometric password is cleared and a snackbar tells the user to re-enable in Settings. Tests: SignInViewModelTest gets two new biometric paths; existing ViewModel tests are updated to pass the new mock BiometricInteractor. https://claude.ai/code/session_01AHoV8HEQJ86WRccDv4i6hK --- app/android/src/main/AndroidManifest.xml | 3 + .../softartdev/notedelight/MainActivity.kt | 10 + app/iosApp/iosApp/Info.plist | 2 + core/presentation/build.gradle.kts | 1 + .../adaptive/AdaptiveInteractorTest.kt | 3 + .../settings/SettingsViewModelTest.kt | 5 +- .../security/change/ChangeViewModelTest.kt | 5 +- .../security/confirm/ConfirmViewModelTest.kt | 5 +- .../security/enter/EnterViewModelTest.kt | 5 +- .../signin/SignInViewModelTest.kt | 56 +++- .../interactor/BiometricActivityHolder.kt | 19 ++ .../interactor/BiometricInteractor.android.kt | 205 +++++++++++++++ .../interactor/BiometricInteractor.kt | 18 ++ .../notedelight/interactor/BiometricResult.kt | 14 + .../interactor/SnackbarInteractor.kt | 3 +- .../notedelight/navigation/AppNavGraph.kt | 3 + .../presentation/settings/SettingsResult.kt | 3 + .../settings/SettingsViewModel.kt | 18 ++ .../biometric/BiometricEnrollResult.kt | 28 ++ .../biometric/BiometricEnrollViewModel.kt | 95 +++++++ .../security/change/ChangeViewModel.kt | 11 + .../security/confirm/ConfirmViewModel.kt | 11 + .../settings/security/enter/EnterViewModel.kt | 11 + .../presentation/signin/SignInAction.kt | 6 + .../presentation/signin/SignInResult.kt | 3 +- .../presentation/signin/SignInViewModel.kt | 66 ++++- .../interactor/BiometricInteractor.ios.kt | 243 ++++++++++++++++++ .../interactor/BiometricInteractor.jvm.kt | 22 ++ .../interactor/BiometricInteractor.wasmJs.kt | 22 ++ .../notedelight/di/uiModules.android.kt | 5 + .../composeResources/values-ru/strings.xml | 10 + .../composeResources/values/strings.xml | 10 + .../kotlin/com/softartdev/notedelight/App.kt | 4 + .../notedelight/di/sharedModules.kt | 2 + .../interactor/SnackbarInteractorImpl.kt | 3 + .../dialog/security/BiometricEnrollDialog.kt | 102 ++++++++ .../settings/detail/SettingsDetailScreen.kt | 21 ++ .../notedelight/ui/signin/SignInScreen.kt | 47 +++- .../softartdev/notedelight/util/TestTags.kt | 7 + .../notedelight/di/uiModules.ios.kt | 2 + .../notedelight/di/uiModules.jvm.kt | 2 + .../notedelight/di/uiModules.wasmJs.kt | 2 + gradle/libs.versions.toml | 2 + 43 files changed, 1097 insertions(+), 18 deletions(-) create mode 100644 core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricActivityHolder.kt 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/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricResult.kt create mode 100644 core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollResult.kt create mode 100644 core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.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 create mode 100644 core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricEnrollDialog.kt diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml index ee9f8281e..6826db5d9 100644 --- a/app/android/src/main/AndroidManifest.xml +++ b/app/android/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + diff --git a/app/android/src/main/java/com/softartdev/notedelight/MainActivity.kt b/app/android/src/main/java/com/softartdev/notedelight/MainActivity.kt index bb609f72a..c7452f309 100644 --- a/app/android/src/main/java/com/softartdev/notedelight/MainActivity.kt +++ b/app/android/src/main/java/com/softartdev/notedelight/MainActivity.kt @@ -3,13 +3,23 @@ package com.softartdev.notedelight import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity +import com.softartdev.notedelight.interactor.BiometricActivityHolder +import org.koin.android.ext.android.inject class MainActivity : AppCompatActivity() { + private val biometricActivityHolder: BiometricActivityHolder by inject() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + biometricActivityHolder.attach(this) setContent { App() } } + + override fun onDestroy() { + biometricActivityHolder.detach() + super.onDestroy() + } } 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/build.gradle.kts b/core/presentation/build.gradle.kts index 4fcf91320..bd4787c2c 100644 --- a/core/presentation/build.gradle.kts +++ b/core/presentation/build.gradle.kts @@ -47,6 +47,7 @@ kotlin { } androidMain.dependencies { implementation(libs.androidx.appcompat) + implementation(libs.androidx.biometric) } val androidHostTest by getting { dependencies { 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..cfa7f9733 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 @@ -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..94ffe1757 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 @@ -44,6 +45,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 +60,13 @@ class SettingsViewModelTest { revealFileListUseCase = RevealFileListUseCase(), localeInteractor = mockLocaleInteractor, adaptiveInteractor = adaptiveInteractor, + biometricInteractor = mockBiometricInteractor, coroutineDispatchers = coroutineDispatchers, ) @After fun tearDown() = runTest { - Mockito.reset(mockSafeRepo, mockSnackbarInteractor, mockRouter, mockAppVersionUseCase) + Mockito.reset(mockSafeRepo, mockSnackbarInteractor, mockRouter, mockAppVersionUseCase, mockBiometricInteractor) } @Test 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..6f864f0d3 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 @@ -34,6 +35,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,6 +44,7 @@ class ChangeViewModelTest { private val viewModel = ChangeViewModel( checkPasswordUseCase = mockCheckPasswordUseCase, changePasswordUseCase = mockChangePasswordUseCase, + biometricInteractor = mockBiometricInteractor, snackbarInteractor = mockSnackbarInteractor, router = mockRouter, coroutineDispatchers = coroutineDispatchers @@ -53,7 +56,7 @@ class ChangeViewModelTest { @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..ce2117669 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,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 @@ -32,6 +33,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,6 +41,7 @@ class ConfirmViewModelTest { ) private val viewModel = ConfirmViewModel( changePasswordUseCase = mockChangePasswordUseCase, + biometricInteractor = mockBiometricInteractor, snackbarInteractor = mockSnackbarInteractor, router = mockRouter, coroutineDispatchers = coroutineDispatchers @@ -50,7 +53,7 @@ class ConfirmViewModelTest { @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..4f162c0df 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 @@ -34,6 +35,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,6 +44,7 @@ class EnterViewModelTest { private val viewModel = EnterViewModel( checkPasswordUseCase = mockCheckPasswordUseCase, changePasswordUseCase = mockChangePasswordUseCase, + biometricInteractor = mockBiometricInteractor, snackbarInteractor = mockSnackbarInteractor, router = mockRouter, coroutineDispatchers = coroutineDispatchers @@ -53,7 +56,7 @@ class EnterViewModelTest { @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..80a40b5e1 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,9 @@ import androidx.compose.ui.autofill.AutofillManager import app.cash.turbine.test import com.softartdev.notedelight.StubEditable import com.softartdev.notedelight.anyObject +import com.softartdev.notedelight.interactor.BiometricInteractor +import com.softartdev.notedelight.interactor.BiometricResult +import com.softartdev.notedelight.interactor.DecryptedPasswordResult import com.softartdev.notedelight.navigation.AppNavGraph import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.presentation.MainDispatcherRule @@ -12,6 +15,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,14 +32,17 @@ 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 lateinit var signInViewModel: SignInViewModel @Before fun setUp() { - signInViewModel = SignInViewModel(mockCheckPasswordUseCase, mockRouter) + signInViewModel = SignInViewModel( + mockCheckPasswordUseCase, mockBiometricInteractor, mockRouter + ) signInViewModel.autofillManager = mockAutofillManager } @@ -114,4 +122,48 @@ class SignInViewModelTest { cancelAndIgnoreRemainingEvents() } } + + @Test + fun refreshBiometricVisibleWhenAvailable() = runTest { + Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(true) + Mockito.`when`(mockBiometricInteractor.canAuthenticate()).thenReturn(true) + signInViewModel.biometricVisibleFlow.test { + assertFalse(awaitItem()) + signInViewModel.onAction(SignInAction.RefreshBiometric) + assertTrue(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun biometricSignInSuccess() = runTest { + val pass = StubEditable("pass") + Mockito.`when`( + mockBiometricInteractor.decryptStoredPassword( + anyObject(), anyObject(), anyObject() + ) + ).thenReturn(DecryptedPasswordResult.Success(pass)) + Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true) + signInViewModel.stateFlow.test { + assertEquals(SignInResult.ShowSignInForm, awaitItem()) + signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c")) + Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Main) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun biometricSignInUnavailableClearsState() = runTest { + Mockito.`when`( + mockBiometricInteractor.decryptStoredPassword( + anyObject(), anyObject(), anyObject() + ) + ).thenReturn(DecryptedPasswordResult.Failure(BiometricResult.Unavailable)) + signInViewModel.biometricVisibleFlow.test { + assertFalse(awaitItem()) + signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c")) + Mockito.verify(mockBiometricInteractor).clearStoredPassword() + cancelAndIgnoreRemainingEvents() + } + } } \ No newline at end of file diff --git a/core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricActivityHolder.kt b/core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricActivityHolder.kt new file mode 100644 index 000000000..4728063ca --- /dev/null +++ b/core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricActivityHolder.kt @@ -0,0 +1,19 @@ +package com.softartdev.notedelight.interactor + +import androidx.appcompat.app.AppCompatActivity +import java.lang.ref.WeakReference + +class BiometricActivityHolder { + private var ref: WeakReference? = null + + fun attach(activity: AppCompatActivity) { + ref = WeakReference(activity) + } + + fun detach() { + ref?.clear() + ref = null + } + + fun current(): AppCompatActivity? = ref?.get() +} 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..77f5e4832 --- /dev/null +++ b/core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt @@ -0,0 +1,205 @@ +package com.softartdev.notedelight.interactor + +import android.content.Context +import android.content.SharedPreferences +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 kotlinx.coroutines.suspendCancellableCoroutine +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 + +actual class BiometricInteractor( + private val context: Context, + private val activityHolder: BiometricActivityHolder, +) { + private val prefs: SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + actual suspend fun canAuthenticate(): Boolean { + val mgr = BiometricManager.from(context) + return mgr.canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS + } + + actual fun hasStoredPassword(): Boolean = + prefs.contains(KEY_CIPHERTEXT) && prefs.contains(KEY_IV) + + actual suspend fun encryptAndStorePassword( + password: CharSequence, + title: String, + subtitle: String, + negativeButton: String, + ): BiometricResult { + val activity = activityHolder.current() + ?: return BiometricResult.Error("No active Activity for BiometricPrompt") + clearStoredPassword() + val secretKey = try { + createOrGetKey() + } catch (t: Throwable) { + return BiometricResult.Error(t.message ?: "Keystore failure") + } + val cipher = Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.ENCRYPT_MODE, secretKey) + } + return when (val auth = runPrompt(activity, cipher, title, subtitle, negativeButton)) { + is PromptOutcome.Authenticated -> { + val out = auth.cipher.doFinal(password.toString().toByteArray(Charsets.UTF_8)) + prefs.edit() + .putString(KEY_CIPHERTEXT, Base64.encodeToString(out, Base64.NO_WRAP)) + .putString(KEY_IV, Base64.encodeToString(auth.cipher.iv, Base64.NO_WRAP)) + .apply() + BiometricResult.Success + } + is PromptOutcome.Failure -> auth.result + } + } + + actual suspend fun decryptStoredPassword( + title: String, + subtitle: String, + negativeButton: String, + ): DecryptedPasswordResult { + if (!hasStoredPassword()) { + return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) + } + val activity = activityHolder.current() + ?: return DecryptedPasswordResult.Failure( + BiometricResult.Error("No active Activity for BiometricPrompt") + ) + val ciphertext = Base64.decode(prefs.getString(KEY_CIPHERTEXT, null), Base64.NO_WRAP) + val iv = Base64.decode(prefs.getString(KEY_IV, null), Base64.NO_WRAP) + val secretKey = try { + existingKey() ?: run { + clearStoredPassword() + return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) + } + } catch (t: KeyPermanentlyInvalidatedException) { + clearStoredPassword() + return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) + } catch (t: Throwable) { + return DecryptedPasswordResult.Failure( + BiometricResult.Error(t.message ?: "Keystore failure") + ) + } + val cipher = try { + Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(GCM_TAG_BITS, iv)) + } + } catch (t: KeyPermanentlyInvalidatedException) { + clearStoredPassword() + return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) + } catch (t: Throwable) { + return DecryptedPasswordResult.Failure( + BiometricResult.Error(t.message ?: "Cipher init failed") + ) + } + return when (val auth = runPrompt(activity, cipher, title, subtitle, negativeButton)) { + is PromptOutcome.Authenticated -> { + val plain = auth.cipher.doFinal(ciphertext) + DecryptedPasswordResult.Success(plain.toString(Charsets.UTF_8)) + } + is PromptOutcome.Failure -> DecryptedPasswordResult.Failure(auth.result) + } + } + + actual fun clearStoredPassword() { + prefs.edit().remove(KEY_CIPHERTEXT).remove(KEY_IV).apply() + 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 generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + val spec = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setUserAuthenticationRequired(true) + .setInvalidatedByBiometricEnrollment(true) + .build() + generator.init(spec) + return generator.generateKey() + } + + private suspend fun runPrompt( + activity: androidx.appcompat.app.AppCompatActivity, + cipher: Cipher, + title: String, + subtitle: String, + negativeButton: String, + ): PromptOutcome = suspendCancellableCoroutine { continuation -> + val executor = ContextCompat.getMainExecutor(context) + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + val resultCipher = result.cryptoObject?.cipher + if (resultCipher == null) { + continuation.resume( + PromptOutcome.Failure(BiometricResult.Error("Missing CryptoObject")) + ) + } else { + continuation.resume(PromptOutcome.Authenticated(resultCipher)) + } + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + val mapped = 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()) + } + continuation.resume(PromptOutcome.Failure(mapped)) + } + + override fun onAuthenticationFailed() { + // Triggered on a wrong fingerprint; system gives the user another try, so do not + // resume the continuation here. The terminal callback is onAuthenticationError. + } + } + val prompt = BiometricPrompt(activity, executor, callback) + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setNegativeButtonText(negativeButton) + .setAllowedAuthenticators(BIOMETRIC_STRONG) + .build() + prompt.authenticate(info, BiometricPrompt.CryptoObject(cipher)) + continuation.invokeOnCancellation { runCatching { prompt.cancelAuthentication() } } + } + + 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 + private const val PREFS_NAME = "notedelight_biometric_prefs" + private const val KEY_CIPHERTEXT = "ciphertext" + private const val KEY_IV = "iv" + } +} 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..b10b9c5fe --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt @@ -0,0 +1,18 @@ +package com.softartdev.notedelight.interactor + +expect class BiometricInteractor { + suspend fun canAuthenticate(): Boolean + fun hasStoredPassword(): Boolean + suspend fun encryptAndStorePassword( + password: CharSequence, + title: String, + subtitle: String, + negativeButton: String, + ): BiometricResult + suspend fun decryptStoredPassword( + title: String, + subtitle: String, + negativeButton: String, + ): DecryptedPasswordResult + fun clearStoredPassword() +} diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricResult.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricResult.kt new file mode 100644 index 000000000..3cba47fc7 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricResult.kt @@ -0,0 +1,14 @@ +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 class Failure(val result: BiometricResult) : DecryptedPasswordResult +} 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..c4e4e85df 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,9 @@ sealed interface AppNavGraph { @Serializable data object ChangePasswordDialog : AppNavGraph + @Serializable + data object BiometricEnrollDialog : 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..bb40656a9 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,19 @@ class SettingsViewModel( } } + private fun changeBiometric(checked: Boolean) = viewModelScope.launch { + try { + if (checked) { + router.navigate(route = AppNavGraph.BiometricEnrollDialog) + } else { + biometricInteractor.clearStoredPassword() + mutableStateFlow.update { it.copy(biometricEnabled = false) } + } + } 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/biometric/BiometricEnrollResult.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollResult.kt new file mode 100644 index 000000000..6663a449e --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollResult.kt @@ -0,0 +1,28 @@ +package com.softartdev.notedelight.presentation.settings.security.biometric + +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 hideError(): BiometricEnrollResult = copy(isError = false) + 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, + ) : BiometricEnrollAction +} diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt new file mode 100644 index 000000000..4c4548b54 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt @@ -0,0 +1,95 @@ +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.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( + action.title, action.subtitle, action.negativeButton + ) + } + + private fun onEditPassword(password: String) = viewModelScope.launch { + mutableStateFlow.update(BiometricEnrollResult::hideError) + mutableStateFlow.update { it.copy(fieldLabel = FieldLabel.ENTER_PASSWORD) } + mutableStateFlow.update { it.copy(password = password) } + } + + private fun togglePasswordVisibility() = viewModelScope.launch { + mutableStateFlow.update(BiometricEnrollResult::togglePasswordVisibility) + } + + private fun enroll( + title: String, + subtitle: String, + negativeButton: String, + ) = viewModelScope.launch(context = coroutineDispatchers.io) { + CountingIdlingRes.increment() + mutableStateFlow.update(BiometricEnrollResult::showLoading) + try { + val password = mutableStateFlow.value.password + when { + password.isEmpty() -> { + mutableStateFlow.update { it.copy(fieldLabel = FieldLabel.EMPTY_PASSWORD) } + mutableStateFlow.update(BiometricEnrollResult::showError) + } + !checkPasswordUseCase(password) -> { + mutableStateFlow.update { it.copy(fieldLabel = FieldLabel.INCORRECT_PASSWORD) } + mutableStateFlow.update(BiometricEnrollResult::showError) + } + else -> { + val result = biometricInteractor.encryptAndStorePassword( + password, title, subtitle, negativeButton + ) + if (result is BiometricResult.Success) { + withContext(coroutineDispatchers.main) { + router.popBackStack() + } + } else { + snackbarInteractor.showMessage(SnackbarMessage.Simple(result.toString())) + } + } + } + } 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/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..1a5184768 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, @@ -76,6 +79,14 @@ class ChangeViewModel( } checkPasswordUseCase(oldPassword) -> { changePasswordUseCase(oldPassword, newPassword) + if (biometricInteractor.hasStoredPassword()) { + biometricInteractor.clearStoredPassword() + snackbarInteractor.showMessage( + SnackbarMessage.Resource( + 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..3b47f87c4 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, @@ -68,6 +71,14 @@ class ConfirmViewModel( } else -> { changePasswordUseCase(null, password) + if (biometricInteractor.hasStoredPassword()) { + biometricInteractor.clearStoredPassword() + snackbarInteractor.showMessage( + SnackbarMessage.Resource( + 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..b63dcf603 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, @@ -58,6 +61,14 @@ class EnterViewModel( } checkPasswordUseCase(password) -> { changePasswordUseCase(password, null) + if (biometricInteractor.hasStoredPassword()) { + biometricInteractor.clearStoredPassword() + snackbarInteractor.showMessage( + SnackbarMessage.Resource( + 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..2aab06a5c 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 @@ -3,4 +3,10 @@ package com.softartdev.notedelight.presentation.signin 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, + ) : 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..17573ef14 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 @@ -4,5 +4,6 @@ enum class SignInResult(val isError: Boolean = false) { ShowSignInForm, ShowProgress, ShowEmptyPassError(isError = true), - ShowIncorrectPassError(isError = true) + ShowIncorrectPassError(isError = true), + ShowBiometricError(isError = true), } 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..75c5c1f25 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,6 +4,9 @@ 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.DecryptedPasswordResult +import com.softartdev.notedelight.interactor.BiometricResult import com.softartdev.notedelight.navigation.AppNavGraph import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase @@ -14,6 +17,7 @@ import kotlinx.coroutines.launch class SignInViewModel( private val checkPasswordUseCase: CheckPasswordUseCase, + private val biometricInteractor: BiometricInteractor, private val router: Router ) : ViewModel() { private val logger = Logger.withTag(this@SignInViewModel::class.simpleName.toString()) @@ -21,26 +25,62 @@ class SignInViewModel( value = SignInResult.ShowSignInForm ) val stateFlow: StateFlow = mutableStateFlow + + private val mutableBiometricVisibleFlow: MutableStateFlow = MutableStateFlow(false) + val biometricVisibleFlow: StateFlow = mutableBiometricVisibleFlow + var autofillManager: AutofillManager? = null 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( + action.title, action.subtitle, action.negativeButton + ) } - private fun signIn(pass: CharSequence) = viewModelScope.launch { + private fun refreshBiometric() = viewModelScope.launch { + mutableBiometricVisibleFlow.value = + biometricInteractor.hasStoredPassword() && biometricInteractor.canAuthenticate() + } + + private fun signInWithBiometric( + title: String, + subtitle: String, + negativeButton: String, + ) = viewModelScope.launch { CountingIdlingRes.increment() mutableStateFlow.value = SignInResult.ShowProgress try { - mutableStateFlow.value = when { - pass.isEmpty() -> SignInResult.ShowEmptyPassError - checkPasswordUseCase(pass) -> { - autofillManager?.commit() - router.navigateClearingBackStack(AppNavGraph.Main) - SignInResult.ShowSignInForm + when (val res = biometricInteractor.decryptStoredPassword( + title, subtitle, negativeButton + )) { + is DecryptedPasswordResult.Success -> signInInternal(res.password) + is DecryptedPasswordResult.Failure -> when (res.result) { + BiometricResult.Cancelled -> mutableStateFlow.value = SignInResult.ShowSignInForm + BiometricResult.Unavailable -> { + biometricInteractor.clearStoredPassword() + mutableBiometricVisibleFlow.value = false + mutableStateFlow.value = SignInResult.ShowSignInForm + } + else -> mutableStateFlow.value = SignInResult.ShowBiometricError } - else -> SignInResult.ShowIncorrectPassError } + } catch (error: Throwable) { + logger.e(error) { "Error during biometric sign in" } + router.navigate(route = AppNavGraph.ErrorDialog(message = error.message)) + mutableStateFlow.value = SignInResult.ShowSignInForm + } finally { + CountingIdlingRes.decrement() + } + } + + private fun signIn(pass: CharSequence) = viewModelScope.launch { + CountingIdlingRes.increment() + mutableStateFlow.value = SignInResult.ShowProgress + try { + mutableStateFlow.value = signInInternal(pass) } catch (error: Throwable) { logger.e(error) { "Error during sign in" } autofillManager?.cancel() @@ -50,4 +90,14 @@ class SignInViewModel( CountingIdlingRes.decrement() } } + + private suspend fun signInInternal(pass: CharSequence): SignInResult = when { + pass.isEmpty() -> SignInResult.ShowEmptyPassError + checkPasswordUseCase(pass) -> { + autofillManager?.commit() + router.navigateClearingBackStack(AppNavGraph.Main) + SignInResult.ShowSignInForm + } + else -> SignInResult.ShowIncorrectPassError + } } 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..ee90199c0 --- /dev/null +++ b/core/presentation/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt @@ -0,0 +1,243 @@ +@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + +package com.softartdev.notedelight.interactor + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.CFBridgingRelease +import kotlinx.cinterop.CFBridgingRetain +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.MemScope +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.CFTypeRefVar +import platform.CoreFoundation.kCFAllocatorDefault +import platform.CoreFoundation.kCFBooleanTrue +import platform.CoreFoundation.kCFTypeDictionaryKeyCallBacks +import platform.CoreFoundation.kCFTypeDictionaryValueCallBacks +import platform.Foundation.NSData +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.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 + +actual class BiometricInteractor { + + actual suspend fun canAuthenticate(): Boolean { + return LAContext().canEvaluatePolicy(LAPolicyDeviceOwnerAuthenticationWithBiometrics, null) + } + + actual fun hasStoredPassword(): Boolean = memScoped { + val service = CFBridgingRetain(SERVICE) + val account = CFBridgingRetain(ACCOUNT) + val query = newMutableDict() + CFDictionaryAddValue(query, kSecClass, kSecClassGenericPassword) + CFDictionaryAddValue(query, kSecAttrService, service) + CFDictionaryAddValue(query, kSecAttrAccount, account) + CFDictionaryAddValue(query, kSecUseAuthenticationUI, kSecUseAuthenticationUIFail) + try { + val status = SecItemCopyMatching(query, null) + status == errSecSuccess || status == errSecInteractionNotAllowed + } finally { + CFRelease(query) + CFRelease(service) + CFRelease(account) + } + } + + actual suspend fun encryptAndStorePassword( + password: CharSequence, + title: String, + subtitle: String, + negativeButton: String, + ): BiometricResult { + clearStoredPassword() + val context = LAContext().apply { + localizedFallbackTitle = "" + localizedCancelTitle = negativeButton + } + val authResult = evaluatePolicy(context, "$title\n$subtitle") + if (authResult !is BiometricResult.Success) return authResult + val accessControl = SecAccessControlCreateWithFlags( + allocator = null, + protection = kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + flags = kSecAccessControlBiometryCurrentSet, + error = null, + ) ?: return BiometricResult.Error("Could not create access control") + val passwordData = (NSString.create(string = password.toString())) + .dataUsingEncoding(NSUTF8StringEncoding) + ?: return BiometricResult.Error("Could not encode password") + return memScoped { + val service = CFBridgingRetain(SERVICE) + val account = CFBridgingRetain(ACCOUNT) + val data = CFBridgingRetain(passwordData) + val ctxRef = CFBridgingRetain(context) + val attrs = 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 { + val status = SecItemAdd(attrs, null) + if (status == errSecSuccess) BiometricResult.Success else mapKeychainStatus(status) + } finally { + CFRelease(attrs) + CFRelease(service) + CFRelease(account) + CFRelease(data) + CFRelease(ctxRef) + } + } + } + + actual suspend fun decryptStoredPassword( + title: String, + subtitle: String, + negativeButton: String, + ): DecryptedPasswordResult { + if (!hasStoredPassword()) { + return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) + } + val context = LAContext().apply { + localizedReason = "$title\n$subtitle" + localizedFallbackTitle = "" + localizedCancelTitle = negativeButton + } + return memScoped { + val resultRef = alloc() + val service = CFBridgingRetain(SERVICE) + val account = CFBridgingRetain(ACCOUNT) + val ctxRef = CFBridgingRetain(context) + val query = newMutableDict() + CFDictionaryAddValue(query, kSecClass, kSecClassGenericPassword) + CFDictionaryAddValue(query, kSecAttrService, service) + CFDictionaryAddValue(query, kSecAttrAccount, account) + CFDictionaryAddValue(query, kSecReturnData, kCFBooleanTrue) + CFDictionaryAddValue(query, kSecUseAuthenticationContext, ctxRef) + val status = 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(BiometricResult.Error("Decoding failed")) + } + } + errSecItemNotFound -> { + clearStoredPassword() + DecryptedPasswordResult.Failure(BiometricResult.Unavailable) + } + else -> DecryptedPasswordResult.Failure(mapKeychainStatus(status)) + } + } + } + + actual fun clearStoredPassword() { + memScoped { + val service = CFBridgingRetain(SERVICE) + val account = CFBridgingRetain(ACCOUNT) + val query = 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 MemScope.newMutableDict(): CFMutableDictionaryRef? = CFDictionaryCreateMutable( + kCFAllocatorDefault, 0, + kCFTypeDictionaryKeyCallBacks.ptr, + kCFTypeDictionaryValueCallBacks.ptr, + ) + + private suspend fun evaluatePolicy(context: LAContext, reason: String): BiometricResult = + suspendCancellableCoroutine { continuation -> + context.evaluatePolicy( + policy = LAPolicyDeviceOwnerAuthenticationWithBiometrics, + localizedReason = reason, + ) { success, error -> + if (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( + 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/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..d347b2e72 --- /dev/null +++ b/core/presentation/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt @@ -0,0 +1,22 @@ +package com.softartdev.notedelight.interactor + +actual class BiometricInteractor { + actual suspend fun canAuthenticate(): Boolean = false + + actual fun hasStoredPassword(): Boolean = false + + actual suspend fun encryptAndStorePassword( + password: CharSequence, + title: String, + subtitle: String, + negativeButton: String, + ): BiometricResult = BiometricResult.Unavailable + + actual suspend fun decryptStoredPassword( + title: String, + subtitle: String, + negativeButton: String, + ): DecryptedPasswordResult = DecryptedPasswordResult.Failure(BiometricResult.Unavailable) + + actual fun clearStoredPassword() = Unit +} 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..d347b2e72 --- /dev/null +++ b/core/presentation/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt @@ -0,0 +1,22 @@ +package com.softartdev.notedelight.interactor + +actual class BiometricInteractor { + actual suspend fun canAuthenticate(): Boolean = false + + actual fun hasStoredPassword(): Boolean = false + + actual suspend fun encryptAndStorePassword( + password: CharSequence, + title: String, + subtitle: String, + negativeButton: String, + ): BiometricResult = BiometricResult.Unavailable + + actual suspend fun decryptStoredPassword( + title: String, + subtitle: String, + negativeButton: String, + ): DecryptedPasswordResult = DecryptedPasswordResult.Failure(BiometricResult.Unavailable) + + actual fun clearStoredPassword() = Unit +} 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..1adca4a6d 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,9 +1,12 @@ package com.softartdev.notedelight.di import com.softartdev.notedelight.interactor.AdaptiveInteractor +import com.softartdev.notedelight.interactor.BiometricActivityHolder +import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.LocaleInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarInteractorImpl +import org.koin.android.ext.koin.androidContext import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf import org.koin.dsl.module @@ -12,4 +15,6 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) + singleOf(::BiometricActivityHolder) + single { BiometricInteractor(androidContext(), get()) } } \ 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..5f7666ab4 100644 --- a/core/ui/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-ru/strings.xml @@ -86,4 +86,14 @@ Введите SQL-запрос Выполнить Подставить + Вход по биометрии + Разблокировка заметок + Подтвердите биометрию для расшифровки пароля + Отмена + По биометрии + Включить вход по биометрии + Подтвердите пароль, чтобы разрешить вход по биометрии. + Вход по биометрии отключён — снова включите его в настройках. + Не удалось пройти биометрию + Биометрия недоступна diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index d8fa04fa2..ee9cd0670 100644 --- a/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/core/ui/src/commonMain/composeResources/values/strings.xml @@ -86,4 +86,14 @@ 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. + 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..287d861c4 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,7 @@ 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.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 +101,9 @@ fun App( dialog { ChangePasswordDialog(changeViewModel = koinViewModel()) } + dialog { + BiometricEnrollDialog(biometricEnrollViewModel = koinViewModel()) + } dialog { backStackEntry: NavBackStackEntry -> ErrorDialog( message = backStackEntry.toRoute().message, 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..c342184bd 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,7 @@ 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.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 +77,7 @@ val viewModelModule: Module = module { viewModelOf(::EnterViewModel) viewModelOf(::ConfirmViewModel) viewModelOf(::ChangeViewModel) + viewModelOf(::BiometricEnrollViewModel) 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..41577fda3 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,8 @@ 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/dialog/security/BiometricEnrollDialog.kt b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricEnrollDialog.kt new file mode 100644 index 000000000..d96af813d --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricEnrollDialog.kt @@ -0,0 +1,102 @@ +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.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.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() + 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) + ShowBiometricEnrollDialog(result) { action -> + val resolved = if (action is BiometricEnrollAction.OnEnrollClick) { + BiometricEnrollAction.OnEnrollClick(title, subtitle, negative) + } else action + biometricEnrollViewModel.onAction(resolved) + } +} + +@Composable +fun ShowBiometricEnrollDialog( + result: BiometricEnrollResult, + onAction: (action: BiometricEnrollAction) -> Unit = {}, +) = 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("", "", "")) + }, + 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("", "", "")) }, + ) + }, + 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..ace0bfca7 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 @@ -34,6 +37,7 @@ 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.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 +45,11 @@ 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_error +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 @@ -52,21 +61,38 @@ import org.jetbrains.compose.resources.stringResource @Composable fun SignInScreen(signInViewModel: SignInViewModel) { val signInResultState: State = signInViewModel.stateFlow.collectAsState() + val biometricVisibleState: State = + signInViewModel.biometricVisibleFlow.collectAsState() val passwordState: MutableState = remember { mutableStateOf("") } val autofillManager: AutofillManager? = LocalAutofillManager.current LaunchedEffect(key1 = signInViewModel, key2 = autofillManager) { signInViewModel.autofillManager = autofillManager } + LaunchedEffect(signInViewModel) { + signInViewModel.onAction(SignInAction.RefreshBiometric) + } + val biometricTitle = stringResource(Res.string.biometric_prompt_title) + val biometricSubtitle = stringResource(Res.string.biometric_prompt_subtitle) + val biometricNegative = stringResource(Res.string.biometric_prompt_negative_button) SignInScreenBody( showLoading = signInResultState.value == SignInResult.ShowProgress, passwordState = passwordState, labelResource = when (signInResultState.value) { SignInResult.ShowEmptyPassError -> Res.string.empty_password SignInResult.ShowIncorrectPassError -> Res.string.incorrect_password + SignInResult.ShowBiometricError -> Res.string.biometric_error else -> Res.string.enter_password }, isError = signInResultState.value.isError, - onAction = signInViewModel::onAction + biometricVisible = biometricVisibleState.value, + onAction = { action -> + val resolved = if (action is SignInAction.OnBiometricClick) { + SignInAction.OnBiometricClick( + biometricTitle, biometricSubtitle, biometricNegative + ) + } else action + signInViewModel.onAction(resolved) + } ) } @@ -77,6 +103,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 +144,26 @@ fun SignInScreenBody( .padding(top = 24.dp), onClick = { onAction(SignInAction.OnSignInClick(passwordState.value)) }, ) { Text(text = stringResource(Res.string.sign_in)) } + if (biometricVisible) { + OutlinedButton( + modifier = Modifier + .testTag(SIGN_IN_BIOMETRIC_BUTTON_TAG) + .fillMaxWidth() + .padding(top = 8.dp), + onClick = { onAction(SignInAction.OnBiometricClick("", "", "")) }, + ) { + 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() 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..d788a215e 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,12 @@ 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 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..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) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ceac655ad..046302c2b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ composeMaterial3 = "1.9.0" composeMaterialIconsExtended = "1.7.3" composeMaterialAdaptive = "1.2.0" androidxAppcompat = "1.7.1" +androidxBiometric = "1.2.0-alpha05" androidxViewModel = "2.10.0" androidxNavigation = "2.9.2" androidxNavigationEvent = "1.0.1" @@ -120,6 +121,7 @@ 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-ktx", version.ref = "androidxBiometric" } 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" } From babf4942128cc485df552689bcf7932b3faf49d2 Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Tue, 28 Apr 2026 17:29:24 +0400 Subject: [PATCH 02/13] refactor: enhance biometric authentication logic and platform implementations - Refactor `BiometricInteractor` on iOS to improve type safety with explicit `CFTypeRef` and `OSStatus` handling, and simplify `memScoped` logic. - Update `BiometricInteractor` on Android to utilize `SharedPreferences.edit` KTX extension, add detailed logging for Keystore failures, and refactor cipher initialization. - Optimize `BiometricEnrollViewModel` state updates to use single `update` blocks and named arguments for better readability. - Simplify `SignInScreen` and `BiometricEnrollDialog` by resolving string resources directly within the UI components and streamlining action passing. - Switch iOS project configuration from manual to automatic code signing and remove platform-specific development team overrides. - Refactor Android Koin dependency injection to use `singleOf` for `BiometricInteractor`. - Add documentation/TODOs regarding the removal of Compose-specific `AutofillManager` dependencies from presentation ViewModels. - Clean up unused methods, such as `BiometricEnrollResult.hideError()`, and improve formatting across the presentation and UI modules. - Update iOS user interface state and workspace configuration. --- .../signin/SignInViewModelTest.kt | 14 +-- .../interactor/BiometricInteractor.android.kt | 66 +++++----- .../interactor/BiometricInteractor.kt | 5 + .../biometric/BiometricEnrollResult.kt | 1 - .../biometric/BiometricEnrollViewModel.kt | 47 ++++--- .../security/change/ChangeViewModel.kt | 6 +- .../security/confirm/ConfirmViewModel.kt | 6 +- .../settings/security/enter/EnterViewModel.kt | 6 +- .../presentation/signin/SignInViewModel.kt | 17 +-- .../interactor/BiometricInteractor.ios.kt | 117 +++++++++--------- .../notedelight/di/uiModules.android.kt | 3 +- .../interactor/SnackbarInteractorImpl.kt | 3 +- .../dialog/security/BiometricEnrollDialog.kt | 17 +-- .../notedelight/ui/signin/SignInScreen.kt | 17 +-- 14 files changed, 163 insertions(+), 162 deletions(-) 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 80a40b5e1..d052e8dd2 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 @@ -138,11 +138,8 @@ class SignInViewModelTest { @Test fun biometricSignInSuccess() = runTest { val pass = StubEditable("pass") - Mockito.`when`( - mockBiometricInteractor.decryptStoredPassword( - anyObject(), anyObject(), anyObject() - ) - ).thenReturn(DecryptedPasswordResult.Success(pass)) + Mockito.`when`(mockBiometricInteractor.decryptStoredPassword(anyObject(), anyObject(), anyObject())) + .thenReturn(DecryptedPasswordResult.Success(pass)) Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true) signInViewModel.stateFlow.test { assertEquals(SignInResult.ShowSignInForm, awaitItem()) @@ -154,11 +151,8 @@ class SignInViewModelTest { @Test fun biometricSignInUnavailableClearsState() = runTest { - Mockito.`when`( - mockBiometricInteractor.decryptStoredPassword( - anyObject(), anyObject(), anyObject() - ) - ).thenReturn(DecryptedPasswordResult.Failure(BiometricResult.Unavailable)) + Mockito.`when`(mockBiometricInteractor.decryptStoredPassword(anyObject(), anyObject(), anyObject())) + .thenReturn(DecryptedPasswordResult.Failure(BiometricResult.Unavailable)) signInViewModel.biometricVisibleFlow.test { assertFalse(awaitItem()) signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c")) 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 index 77f5e4832..1bb45504c 100644 --- 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 @@ -6,10 +6,12 @@ import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.KeyProperties import android.util.Base64 +import androidx.appcompat.app.AppCompatActivity import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat +import co.touchlab.kermit.Logger import kotlinx.coroutines.suspendCancellableCoroutine import java.security.KeyStore import javax.crypto.Cipher @@ -17,17 +19,19 @@ import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec import kotlin.coroutines.resume +import androidx.core.content.edit actual class BiometricInteractor( private val context: Context, private val activityHolder: BiometricActivityHolder, ) { + private val logger = Logger.withTag("BiometricInteractor") private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) actual suspend fun canAuthenticate(): Boolean { - val mgr = BiometricManager.from(context) - return mgr.canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS + val bm = BiometricManager.from(context) + return bm.canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS } actual fun hasStoredPassword(): Boolean = @@ -45,6 +49,7 @@ actual class BiometricInteractor( val secretKey = try { createOrGetKey() } catch (t: Throwable) { + logger.e(t) { "Keystore failure" } return BiometricResult.Error(t.message ?: "Keystore failure") } val cipher = Cipher.getInstance(TRANSFORMATION).apply { @@ -53,10 +58,10 @@ actual class BiometricInteractor( return when (val auth = runPrompt(activity, cipher, title, subtitle, negativeButton)) { is PromptOutcome.Authenticated -> { val out = auth.cipher.doFinal(password.toString().toByteArray(Charsets.UTF_8)) - prefs.edit() - .putString(KEY_CIPHERTEXT, Base64.encodeToString(out, Base64.NO_WRAP)) - .putString(KEY_IV, Base64.encodeToString(auth.cipher.iv, Base64.NO_WRAP)) - .apply() + prefs.edit { + putString(KEY_CIPHERTEXT, Base64.encodeToString(out, Base64.NO_WRAP)) + putString(KEY_IV, Base64.encodeToString(auth.cipher.iv, Base64.NO_WRAP)) + } BiometricResult.Success } is PromptOutcome.Failure -> auth.result @@ -71,40 +76,43 @@ actual class BiometricInteractor( if (!hasStoredPassword()) { return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) } - val activity = activityHolder.current() - ?: return DecryptedPasswordResult.Failure( - BiometricResult.Error("No active Activity for BiometricPrompt") - ) - val ciphertext = Base64.decode(prefs.getString(KEY_CIPHERTEXT, null), Base64.NO_WRAP) - val iv = Base64.decode(prefs.getString(KEY_IV, null), Base64.NO_WRAP) - val secretKey = try { + val activity = activityHolder.current() ?: return DecryptedPasswordResult.Failure( + result = BiometricResult.Error("No active Activity for BiometricPrompt") + ) + val ciphertext: ByteArray? = Base64.decode(prefs.getString(KEY_CIPHERTEXT, null), Base64.NO_WRAP) + val iv: ByteArray? = Base64.decode(prefs.getString(KEY_IV, null), Base64.NO_WRAP) + val secretKey: SecretKey = try { existingKey() ?: run { clearStoredPassword() return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) } } catch (t: KeyPermanentlyInvalidatedException) { + logger.e(t) { "Key permanently invalidated" } clearStoredPassword() return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) } catch (t: Throwable) { + logger.e(t) { "Keystore failure" } return DecryptedPasswordResult.Failure( - BiometricResult.Error(t.message ?: "Keystore failure") + result = BiometricResult.Error(t.message ?: "Keystore failure") ) } - val cipher = try { + 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.Failure(BiometricResult.Unavailable) } catch (t: Throwable) { + logger.e(t) { "Cipher init failed" } return DecryptedPasswordResult.Failure( - BiometricResult.Error(t.message ?: "Cipher init failed") + result = BiometricResult.Error(t.message ?: "Cipher init failed") ) } - return when (val auth = runPrompt(activity, cipher, title, subtitle, negativeButton)) { + return when (val auth: PromptOutcome = runPrompt(activity, cipher, title, subtitle, negativeButton)) { is PromptOutcome.Authenticated -> { - val plain = auth.cipher.doFinal(ciphertext) + val plain: ByteArray = auth.cipher.doFinal(ciphertext) DecryptedPasswordResult.Success(plain.toString(Charsets.UTF_8)) } is PromptOutcome.Failure -> DecryptedPasswordResult.Failure(auth.result) @@ -112,24 +120,28 @@ actual class BiometricInteractor( } actual fun clearStoredPassword() { - prefs.edit().remove(KEY_CIPHERTEXT).remove(KEY_IV).apply() + prefs.edit { + remove(KEY_CIPHERTEXT) + remove(KEY_IV) + } runCatching { - KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }.deleteEntry(KEY_ALIAS) + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) + keyStore.deleteEntry(KEY_ALIAS) } } private fun existingKey(): SecretKey? { - val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) return keyStore.getKey(KEY_ALIAS, null) as? SecretKey } private fun createOrGetKey(): SecretKey { existingKey()?.let { return it } val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) - val spec = KeyGenParameterSpec.Builder( - KEY_ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, - ) + val spec = KeyGenParameterSpec + .Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setUserAuthenticationRequired(true) @@ -140,7 +152,7 @@ actual class BiometricInteractor( } private suspend fun runPrompt( - activity: androidx.appcompat.app.AppCompatActivity, + activity: AppCompatActivity, cipher: Cipher, title: String, subtitle: String, @@ -158,7 +170,6 @@ actual class BiometricInteractor( continuation.resume(PromptOutcome.Authenticated(resultCipher)) } } - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { val mapped = when (errorCode) { BiometricPrompt.ERROR_USER_CANCELED, @@ -171,7 +182,6 @@ actual class BiometricInteractor( } continuation.resume(PromptOutcome.Failure(mapped)) } - override fun onAuthenticationFailed() { // Triggered on a wrong fingerprint; system gives the user another try, so do not // resume the continuation here. The terminal callback is onAuthenticationError. 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 index b10b9c5fe..ffdb53345 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt @@ -1,18 +1,23 @@ package com.softartdev.notedelight.interactor expect class BiometricInteractor { + suspend fun canAuthenticate(): Boolean + fun hasStoredPassword(): Boolean + suspend fun encryptAndStorePassword( password: CharSequence, title: String, subtitle: String, negativeButton: String, ): BiometricResult + suspend fun decryptStoredPassword( title: String, subtitle: String, negativeButton: String, ): DecryptedPasswordResult + fun clearStoredPassword() } diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollResult.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollResult.kt index 6663a449e..45c835c34 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollResult.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollResult.kt @@ -12,7 +12,6 @@ data class BiometricEnrollResult( fun showLoading(): BiometricEnrollResult = copy(loading = true) fun hideLoading(): BiometricEnrollResult = copy(loading = false) fun showError(): BiometricEnrollResult = copy(isError = true) - fun hideError(): BiometricEnrollResult = copy(isError = false) fun togglePasswordVisibility(): BiometricEnrollResult = copy(isPasswordVisible = !isPasswordVisible) } diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt index 4c4548b54..09e072529 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt @@ -26,6 +26,7 @@ class BiometricEnrollViewModel( 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 @@ -35,14 +36,18 @@ class BiometricEnrollViewModel( is BiometricEnrollAction.OnEditPassword -> onEditPassword(action.password) is BiometricEnrollAction.TogglePasswordVisibility -> togglePasswordVisibility() is BiometricEnrollAction.OnEnrollClick -> enroll( - action.title, action.subtitle, action.negativeButton + title = action.title, + subtitle = action.subtitle, + negativeButton = action.negativeButton ) } - private fun onEditPassword(password: String) = viewModelScope.launch { - mutableStateFlow.update(BiometricEnrollResult::hideError) - mutableStateFlow.update { it.copy(fieldLabel = FieldLabel.ENTER_PASSWORD) } - mutableStateFlow.update { it.copy(password = password) } + 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 { @@ -57,28 +62,32 @@ class BiometricEnrollViewModel( CountingIdlingRes.increment() mutableStateFlow.update(BiometricEnrollResult::showLoading) try { - val password = mutableStateFlow.value.password + val password: String = mutableStateFlow.value.password when { password.isEmpty() -> { mutableStateFlow.update { it.copy(fieldLabel = FieldLabel.EMPTY_PASSWORD) } mutableStateFlow.update(BiometricEnrollResult::showError) } - !checkPasswordUseCase(password) -> { - mutableStateFlow.update { it.copy(fieldLabel = FieldLabel.INCORRECT_PASSWORD) } - mutableStateFlow.update(BiometricEnrollResult::showError) - } - else -> { - val result = biometricInteractor.encryptAndStorePassword( - password, title, subtitle, negativeButton + checkPasswordUseCase(password) -> { + val result: BiometricResult = biometricInteractor.encryptAndStorePassword( + password = password, + title = title, + subtitle = subtitle, + negativeButton = negativeButton ) - if (result is BiometricResult.Success) { - withContext(coroutineDispatchers.main) { + when (result) { + is BiometricResult.Success -> withContext(coroutineDispatchers.main) { router.popBackStack() } - } else { - snackbarInteractor.showMessage(SnackbarMessage.Simple(result.toString())) + else -> snackbarInteractor.showMessage( + message = SnackbarMessage.Simple(result.toString()) + ) } } + else -> { + mutableStateFlow.update { it.copy(fieldLabel = FieldLabel.INCORRECT_PASSWORD) } + mutableStateFlow.update(BiometricEnrollResult::showError) + } } } catch (e: Throwable) { logger.e(e) { "Error enrolling biometric" } @@ -89,7 +98,5 @@ class BiometricEnrollViewModel( } } - private fun cancel() = viewModelScope.launch { - router.popBackStack() - } + private fun cancel() = viewModelScope.launch { router.popBackStack() } } 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 1a5184768..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 @@ -31,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() @@ -82,8 +82,8 @@ class ChangeViewModel( if (biometricInteractor.hasStoredPassword()) { biometricInteractor.clearStoredPassword() snackbarInteractor.showMessage( - SnackbarMessage.Resource( - SnackbarTextResource.BIOMETRIC_DISABLED_PASSWORD_CHANGED + message = SnackbarMessage.Resource( + res = SnackbarTextResource.BIOMETRIC_DISABLED_PASSWORD_CHANGED ) ) } 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 3b47f87c4..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 @@ -31,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() @@ -74,8 +74,8 @@ class ConfirmViewModel( if (biometricInteractor.hasStoredPassword()) { biometricInteractor.clearStoredPassword() snackbarInteractor.showMessage( - SnackbarMessage.Resource( - SnackbarTextResource.BIOMETRIC_DISABLED_PASSWORD_CHANGED + message = SnackbarMessage.Resource( + res = SnackbarTextResource.BIOMETRIC_DISABLED_PASSWORD_CHANGED ) ) } 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 b63dcf603..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 @@ -30,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() @@ -64,8 +64,8 @@ class EnterViewModel( if (biometricInteractor.hasStoredPassword()) { biometricInteractor.clearStoredPassword() snackbarInteractor.showMessage( - SnackbarMessage.Resource( - SnackbarTextResource.BIOMETRIC_DISABLED_PASSWORD_CHANGED + message = SnackbarMessage.Resource( + res = SnackbarTextResource.BIOMETRIC_DISABLED_PASSWORD_CHANGED ) ) } 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 75c5c1f25..70919d449 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 @@ -36,26 +36,21 @@ class SignInViewModel( is SignInAction.OnSignInClick -> signIn(action.pass) is SignInAction.RefreshBiometric -> refreshBiometric() is SignInAction.OnBiometricClick -> signInWithBiometric( - action.title, action.subtitle, action.negativeButton + title = action.title, + subtitle = action.subtitle, + negativeButton = action.negativeButton ) } private fun refreshBiometric() = viewModelScope.launch { - mutableBiometricVisibleFlow.value = - biometricInteractor.hasStoredPassword() && biometricInteractor.canAuthenticate() + mutableBiometricVisibleFlow.value = biometricInteractor.hasStoredPassword() && biometricInteractor.canAuthenticate() } - private fun signInWithBiometric( - title: String, - subtitle: String, - negativeButton: String, - ) = viewModelScope.launch { + private fun signInWithBiometric(title: String, subtitle: String, negativeButton: String) = viewModelScope.launch { CountingIdlingRes.increment() mutableStateFlow.value = SignInResult.ShowProgress try { - when (val res = biometricInteractor.decryptStoredPassword( - title, subtitle, negativeButton - )) { + when (val res: DecryptedPasswordResult = biometricInteractor.decryptStoredPassword(title, subtitle, negativeButton)) { is DecryptedPasswordResult.Success -> signInInternal(res.password) is DecryptedPasswordResult.Failure -> when (res.result) { BiometricResult.Cancelled -> mutableStateFlow.value = SignInResult.ShowSignInForm 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 index ee90199c0..ea3ff91d1 100644 --- 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 @@ -3,10 +3,7 @@ package com.softartdev.notedelight.interactor import kotlinx.cinterop.BetaInteropApi -import kotlinx.cinterop.CFBridgingRelease -import kotlinx.cinterop.CFBridgingRetain import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.MemScope import kotlinx.cinterop.alloc import kotlinx.cinterop.memScoped import kotlinx.cinterop.ptr @@ -16,12 +13,16 @@ 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 @@ -36,6 +37,7 @@ 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 @@ -59,21 +61,20 @@ import kotlin.coroutines.resume actual class BiometricInteractor { - actual suspend fun canAuthenticate(): Boolean { - return LAContext().canEvaluatePolicy(LAPolicyDeviceOwnerAuthenticationWithBiometrics, null) - } + actual suspend fun canAuthenticate(): Boolean = LAContext() + .canEvaluatePolicy(LAPolicyDeviceOwnerAuthenticationWithBiometrics, null) actual fun hasStoredPassword(): Boolean = memScoped { - val service = CFBridgingRetain(SERVICE) - val account = CFBridgingRetain(ACCOUNT) - val query = newMutableDict() + 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 = SecItemCopyMatching(query, null) - status == errSecSuccess || status == errSecInteractionNotAllowed + val status: OSStatus = SecItemCopyMatching(query, null) + return@memScoped status == errSecSuccess || status == errSecInteractionNotAllowed } finally { CFRelease(query) CFRelease(service) @@ -92,23 +93,23 @@ actual class BiometricInteractor { localizedFallbackTitle = "" localizedCancelTitle = negativeButton } - val authResult = evaluatePolicy(context, "$title\n$subtitle") + val authResult: BiometricResult = evaluatePolicy(context, "$title\n$subtitle") if (authResult !is BiometricResult.Success) return authResult - val accessControl = SecAccessControlCreateWithFlags( + val accessControl: SecAccessControlRef = SecAccessControlCreateWithFlags( allocator = null, protection = kSecAttrAccessibleWhenUnlockedThisDeviceOnly, flags = kSecAccessControlBiometryCurrentSet, error = null, ) ?: return BiometricResult.Error("Could not create access control") - val passwordData = (NSString.create(string = password.toString())) + val passwordData: NSData = NSString.create(string = password.toString()) .dataUsingEncoding(NSUTF8StringEncoding) ?: return BiometricResult.Error("Could not encode password") return memScoped { - val service = CFBridgingRetain(SERVICE) - val account = CFBridgingRetain(ACCOUNT) - val data = CFBridgingRetain(passwordData) - val ctxRef = CFBridgingRetain(context) - val attrs = newMutableDict() + 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) @@ -116,8 +117,10 @@ actual class BiometricInteractor { CFDictionaryAddValue(attrs, kSecAttrAccessControl, accessControl) CFDictionaryAddValue(attrs, kSecUseAuthenticationContext, ctxRef) try { - val status = SecItemAdd(attrs, null) - if (status == errSecSuccess) BiometricResult.Success else mapKeychainStatus(status) + return@memScoped when (val status: OSStatus = SecItemAdd(attrs, null)) { + errSecSuccess -> BiometricResult.Success + else -> mapKeychainStatus(status) + } } finally { CFRelease(attrs) CFRelease(service) @@ -142,17 +145,17 @@ actual class BiometricInteractor { localizedCancelTitle = negativeButton } return memScoped { - val resultRef = alloc() - val service = CFBridgingRetain(SERVICE) - val account = CFBridgingRetain(ACCOUNT) - val ctxRef = CFBridgingRetain(context) - val query = newMutableDict() + 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 = try { + val status: OSStatus = try { SecItemCopyMatching(query, resultRef.ptr) } finally { CFRelease(query) @@ -181,39 +184,40 @@ actual class BiometricInteractor { } } - actual fun clearStoredPassword() { - memScoped { - val service = CFBridgingRetain(SERVICE) - val account = CFBridgingRetain(ACCOUNT) - val query = newMutableDict() - CFDictionaryAddValue(query, kSecClass, kSecClassGenericPassword) - CFDictionaryAddValue(query, kSecAttrService, service) - CFDictionaryAddValue(query, kSecAttrAccount, account) - try { - SecItemDelete(query) - } finally { - CFRelease(query) - CFRelease(service) - CFRelease(account) - } + actual 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 MemScope.newMutableDict(): CFMutableDictionaryRef? = CFDictionaryCreateMutable( - kCFAllocatorDefault, 0, - kCFTypeDictionaryKeyCallBacks.ptr, - kCFTypeDictionaryValueCallBacks.ptr, + 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 -> - context.evaluatePolicy( - policy = LAPolicyDeviceOwnerAuthenticationWithBiometrics, - localizedReason = reason, - ) { success, error -> - if (success) { - continuation.resume(BiometricResult.Success) - } else { + 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, @@ -223,13 +227,14 @@ actual class BiometricInteractor { LAErrorPasscodeNotSet -> BiometricResult.Unavailable LAErrorAuthenticationFailed -> BiometricResult.Failed else -> BiometricResult.Error( - error?.localizedDescription ?: "LAContext error" + message = error?.localizedDescription ?: "LAContext error" ) } continuation.resume(mapped) } } } + } private fun mapKeychainStatus(status: OSStatus): BiometricResult = when (status) { errSecItemNotFound -> BiometricResult.Unavailable 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 1adca4a6d..054eb6041 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 @@ -6,7 +6,6 @@ import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.LocaleInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarInteractorImpl -import org.koin.android.ext.koin.androidContext import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf import org.koin.dsl.module @@ -16,5 +15,5 @@ actual val interactorModule: Module = module { singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) singleOf(::BiometricActivityHolder) - single { BiometricInteractor(androidContext(), get()) } + singleOf(::BiometricInteractor) } \ No newline at end of file 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 41577fda3..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 @@ -53,8 +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 + 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/dialog/security/BiometricEnrollDialog.kt b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricEnrollDialog.kt index d96af813d..fb1dd9f1b 100644 --- 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 @@ -41,21 +41,16 @@ import org.jetbrains.compose.resources.stringResource @Composable fun BiometricEnrollDialog(biometricEnrollViewModel: BiometricEnrollViewModel) { val result: BiometricEnrollResult by biometricEnrollViewModel.stateFlow.collectAsState() - 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) - ShowBiometricEnrollDialog(result) { action -> - val resolved = if (action is BiometricEnrollAction.OnEnrollClick) { - BiometricEnrollAction.OnEnrollClick(title, subtitle, negative) - } else action - biometricEnrollViewModel.onAction(resolved) - } + 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) ) = AlertDialog( modifier = Modifier.testTag(BIOMETRIC_ENROLL_DIALOG_TAG), title = { Text(text = stringResource(Res.string.biometric_enroll_dialog_title)) }, @@ -73,7 +68,7 @@ fun ShowBiometricEnrollDialog( contentDescription = stringResource(Res.string.enter_password), imeAction = ImeAction.Done, keyboardActions = KeyboardActions { - onAction(BiometricEnrollAction.OnEnrollClick("", "", "")) + onAction(BiometricEnrollAction.OnEnrollClick(title, subtitle, negative)) }, labelTag = BIOMETRIC_ENROLL_DIALOG_LABEL_TAG, visibilityTag = BIOMETRIC_ENROLL_DIALOG_VISIBILITY_TAG, @@ -84,7 +79,7 @@ fun ShowBiometricEnrollDialog( confirmButton = { PasswordSaveButton( tag = BIOMETRIC_ENROLL_DIALOG_SAVE_BUTTON_TAG, - onClick = { onAction(BiometricEnrollAction.OnEnrollClick("", "", "")) }, + onClick = { onAction(BiometricEnrollAction.OnEnrollClick(title, subtitle, negative)) }, ) }, dismissButton = { 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 ace0bfca7..cc411d7a6 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 @@ -71,9 +71,6 @@ fun SignInScreen(signInViewModel: SignInViewModel) { LaunchedEffect(signInViewModel) { signInViewModel.onAction(SignInAction.RefreshBiometric) } - val biometricTitle = stringResource(Res.string.biometric_prompt_title) - val biometricSubtitle = stringResource(Res.string.biometric_prompt_subtitle) - val biometricNegative = stringResource(Res.string.biometric_prompt_negative_button) SignInScreenBody( showLoading = signInResultState.value == SignInResult.ShowProgress, passwordState = passwordState, @@ -85,14 +82,7 @@ fun SignInScreen(signInViewModel: SignInViewModel) { }, isError = signInResultState.value.isError, biometricVisible = biometricVisibleState.value, - onAction = { action -> - val resolved = if (action is SignInAction.OnBiometricClick) { - SignInAction.OnBiometricClick( - biometricTitle, biometricSubtitle, biometricNegative - ) - } else action - signInViewModel.onAction(resolved) - } + onAction = signInViewModel::onAction ) } @@ -145,12 +135,15 @@ fun SignInScreenBody( 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) OutlinedButton( modifier = Modifier .testTag(SIGN_IN_BIOMETRIC_BUTTON_TAG) .fillMaxWidth() .padding(top = 8.dp), - onClick = { onAction(SignInAction.OnBiometricClick("", "", "")) }, + onClick = { onAction(SignInAction.OnBiometricClick(title, subtitle, negative)) }, ) { Icon( imageVector = Icons.Default.Fingerprint, From 9ff71fa2b6890f51b362ec73aef9c4ce0d061d53 Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Tue, 28 Apr 2026 20:30:51 +0400 Subject: [PATCH 03/13] refactor: improve biometric authentication and unify sign-in state - Refactor `BiometricInteractor` on Android to use `Application.ActivityLifecycleCallbacks` for automatic activity tracking, eliminating the manual `BiometricActivityHolder`. - Consolidate `SignInViewModel` UI state into a single `SignInResult` data class, replacing separate flows for result and visibility with atomic state updates. - Update `SignInScreen` and related tests to utilize the unified state model. - Downgrade `androidx.biometric` to `1.1.0` for stability and set `minSdk` to 23 in `CONTRIBUTING.md`. - Add `USE_FINGERPRINT` permission to `AndroidManifest.xml` and remove manual `configChanges` handling for `MainActivity`. - Expand `CONTRIBUTING.md` with detailed coding standards for named arguments, state management, and Composable patterns. - Switch iOS project configuration to use automatic code signing. - Ensure `BiometricInteractor` operations are explicitly dispatched to the main thread when interacting with UI components. --- CONTRIBUTING.md | 56 +++++- app/android/src/main/AndroidManifest.xml | 2 +- .../softartdev/notedelight/MainActivity.kt | 10 -- app/iosApp/iosApp.xcodeproj/project.pbxproj | 11 +- .../UserInterfaceState.xcuserstate | Bin 13963 -> 15756 bytes .../settings/SettingsViewModelTest.kt | 12 ++ .../signin/SignInViewModelTest.kt | 30 ++-- .../interactor/BiometricActivityHolder.kt | 19 -- .../interactor/BiometricInteractor.android.kt | 165 +++++++++++------- .../presentation/signin/SignInResult.kt | 20 ++- .../presentation/signin/SignInViewModel.kt | 54 +++--- .../notedelight/di/uiModules.android.kt | 2 - .../notedelight/ui/signin/SignInScreen.kt | 19 +- gradle/libs.versions.toml | 4 +- 14 files changed, 241 insertions(+), 163 deletions(-) delete mode 100644 core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricActivityHolder.kt 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/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml index 6826db5d9..24c848f17 100644 --- a/app/android/src/main/AndroidManifest.xml +++ b/app/android/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + diff --git a/app/android/src/main/java/com/softartdev/notedelight/MainActivity.kt b/app/android/src/main/java/com/softartdev/notedelight/MainActivity.kt index c7452f309..bb609f72a 100644 --- a/app/android/src/main/java/com/softartdev/notedelight/MainActivity.kt +++ b/app/android/src/main/java/com/softartdev/notedelight/MainActivity.kt @@ -3,23 +3,13 @@ package com.softartdev.notedelight import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity -import com.softartdev.notedelight.interactor.BiometricActivityHolder -import org.koin.android.ext.android.inject class MainActivity : AppCompatActivity() { - private val biometricActivityHolder: BiometricActivityHolder by inject() - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - biometricActivityHolder.attach(this) setContent { App() } } - - override fun onDestroy() { - biometricActivityHolder.detach() - super.onDestroy() - } } diff --git a/app/iosApp/iosApp.xcodeproj/project.pbxproj b/app/iosApp/iosApp.xcodeproj/project.pbxproj index 5c764890b..db7b5e6a8 100644 --- a/app/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/app/iosApp/iosApp.xcodeproj/project.pbxproj @@ -460,12 +460,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = H7L7R3VNZ4; + DEVELOPMENT_TEAM = H7L7R3VNZ4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = iosApp/Info.plist; @@ -475,7 +473,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 8.5.4; + MARKETING_VERSION = 8.5.4; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -487,7 +485,6 @@ PRODUCT_BUNDLE_IDENTIFIER = com.softartdev.notedelight; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = NoteDelight_Development_Profile; SWIFT_OBJC_BRIDGING_HEADER = "iosApp-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -517,7 +514,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 8.5.4; + MARKETING_VERSION = 8.5.4; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/app/iosApp/iosApp.xcworkspace/xcuserdata/artur.xcuserdatad/UserInterfaceState.xcuserstate b/app/iosApp/iosApp.xcworkspace/xcuserdata/artur.xcuserdatad/UserInterfaceState.xcuserstate index f9976c6737c2843e0ad04e50b707657ac0b0b0ed..e9e81c12086aff474ef2d94594338431de8a19ea 100644 GIT binary patch delta 8728 zcmb7I2Ygdi`#$4#q-nF7q(IUnZJN>S(a8`xvB)lzO-b8O3Wb(7Wkb#d0omgM0L$QZNLRgW+H#r~;$FXfPg300gjLDwqalf>~fLSPtF*E5J&y z3akU0!4|L`>;n71``{yR9GnCfz(sHgTn1Oc_uvO`1N;VVgFE0pcmN)PzafAO42O|0 z0UBWW+#~F)Be`G#HhlA!ryXM-^xa zdKFDY)6jJE8k&J-qFHD*szdc?EqW8JL+jB7v=MDWZ=tF@IYLM2VoB$jz{1sJO+=&6Y(TG8BfDA z@hm(aFT(z%cmv*uH{rMNX1oP&#oO?9ydNLH2k|@j2tI=^;xF;n_!_>BzsEn|+xQN? zi|^sz@goK>kU>lY!)GEH5hG?~jGSq~#4?GDi7_)4#>%8KEtytKYo-m8#bh&iOg__t z>B$r_gBTxE!;E4^Gh>=DW0`TxcxD1Kkztvs%nW88GoM+?EaU!#Ucy_+P)>+fHBTg^ zWEd$Y!|C4$GLjH7pKKvBiL2p5JOt)<-2`$$d+;*I10BFCpd;u6I)g4Gn#f5r(t;?7 zifBkYNg#S+JOjD|A;1s$W!g@N1s*Ra*wZUl$QV^BU0{RXhB#X&=>Rr z{ki=iN`F5vpm*o|E=5DV#lB~4c!K?73Po+lSjQAslz6S>6{Rihc1K!jT2`_%)#}d8 z&afuAvQn+->B%WpcTQTWD?KSQIn|xo);G4&+orf;M0G{E_gSNZgGQSdiZYsv=Kg=J zdTPd$m6v&{#y)Fz7`HMs#_!BcO?D(@-*dBP}D{>PU6v=A>t&<))>l zH9~u#k;)*r*k`~sV#{*oX6HH_Zfjq<|}btJoTt?sPkOzO#;6l+FmYOXai%jL?=PRq`6WjOx}+;Ks(aZk;1 zqeEIoKkF5Lu!okr800d0-JpSqtWa1z;iIh>4hqg;>{u#b60|9V{g_l0;r1CB#)IYQ@bDQ;PF_ zRW-%FnksKeRwE<0D`78lbHWC;Y%;kT)Pa;b%JcS=*YY}057rVpaT8ZlhxK41NT~%I zh@%#4B2MDsmKl})(?L92LDx0J*ob)t*jXrw?wsGL#^)(2_hy$B`^qXtP_op69iRaW z=-)a2XHNMeKL(#XkGJVvpMkR=r5>CDr@S5Z~i*p^>bI-+buX=7WX1FnFswctzAh8WL+ufW&f8V%An z;9Kw=xXvBq#f7yaZOKa{m#+LCer?9F&#GEozG80z^dq<#aQ#o%cAW3&|om z#MP^Zr>fNJt1fuCK{eFCI2cbllP;ti=}z)V57LtqkRnq2T%@5P z80k(l(zb&1jP&$uS4M_4&6Pnl$B~?6P0MmSt**4}wA`#rcSc5bM$?2$(DFi7KnHZu zFQAi9?>?n>)38*S{^DV6VA~gekp**}x7TDU4|aIoVVz)?7x&DE-FgQW!D-kNoPxb5 zQ+rXq_FfD7z`n2_>`(fTzN8=V1eW>#N>{Fmm*{^nW10XK!Qw`ML2ARnly9(<^nZrh za3~xWpf(vu1_WpwVD!k5?FZB~nW%)-&oUbNU=1lGgNQ3=(htWx;qZTlG7(OC-d8M~ z@?u|2hcjLrhdFRQg+fVqkdiLB4laNT{lrTKKam=^7%qAKCClLQ=NneS)j(bgSCJvL za1ALVLkHw#=XCZA_flD)`=-5WYjINHy`*!^7}h zc!boDF=RX$S}0Ocq}^*qP}TQ&$N0K?i@hU!+=eKh{}?R-~rKY-_j?}@$C4*B-ia3|h%xg6JCHyycRUoy039kn0phl-QIqPfqZ6JQv z;5TFfnOFzEgV)I<;-@OD6^``>ApZnU1_{`;cAS(#4Nl`$V;hBkMngt7+4>Fs0puIu zZFmRXh4ELtMY zBD2XHGMCJw{}1J~A~#nd%J<8BL8Z?2jw&nmcA|-+Jd556Me=9c^QvhWimSXnFRyo5 z<&cUI-iqo%kt@5PM^;6Zw{fgBIVCGKEh9C}nv|L8uqL~bGOQWSv@EMDBPqp|nwpdB z%yjnjR1Hr_>fdC;mD1Xl)Vr*!rzF+lbUvOeia|;$9;g{=j#?lESwI#Njx1V>Vv!1| zk%lZLOUUbFDRtyd@shU41b5dXGqNBnSw>cqwWPAikOL*re2JXMh1_I0d4sH|N69D! z#gdg|6*WWEN6&4SsJJ&JzE-F$%KAs|Y?Om?$r@5i>WE9*_7@QRDFzxH0-Dq46(B?% zQKyC;(ao5l^+8ggE~x8(C)}r$^VHL(ZeTzo0X?*6VFS}V3+B&3uY;70XfB$E=A#8@A>z;?v=}WR zZ(Ex0e>(tG49@-_L6TxVfVQqIzJ;o_FJ z*lF2}b_8g!1#Ly!&~|c^yiYzLAFf3^Q3KkAc9Ub|3^_}_2zW%p9Z|$^^AtT3527Q1 zLGPeL=rDShd_<0ukI9L(=sk24y^r1_pO8<ppP)~HoEC3Gxrs`x_!K%r z#S)z+Cu`AJ@>#G|L6^D^eSt2Zi`;&t3ZEvYxJ%0Jp|lRV;#t7CW7Fx!#Pv5CxT@Hy z2wJ$HE9lDrtvo6jhgH$x|3*|Bni|}yQTQoZUP2QYuA%R!RG@Frx8!qju8vOddpf!E zfvM@})a2YXm5V#1&^vFUJ0RsvbPN50enr2b+vEbdNG_4fE&5P4; zt>bi@C_b)S1e)g261S$}k6V#DwYUwr8}PN53R-?;d6}=E%sV>I=N(?%=%Ea_yAEgK zEOL+h@njw0T-<@?Fx(!$jPuCv;(`5jcm7a4{|cLhQwZaVZ{x%kWS<43~3P<1+p!e)JK(-2DU8$+}RbwJ!K9W;qW*-o_nZOht%N-v=FDC z^wIvm8-LCO_YYr#?=S&9$V)5?Cv>wymj#}JU*#$iVi-J?j;TnH$I^9$r{mY~jJ}l~ z-;l=C$wEF0BML?7+)ZA(vT@*SJf|OxOKw@Yw=3;fkLTif)PzW<37kaW1$ZHss zY3`B!eHw9wh4Cy*U}0>~T2de&6t4Dprkur}KW54W7RIqq^$bZgjUm4jc>iS{?8XV*4@~F*3r){d_xKO|C;ki2L}F#3 zg@tC4TPW%_m@bRZ^xc3o~k&3>Ieo7cFIS zsFpIhOnVk)u`u_^B4s)-o&QrKnJyrip%R}>X0kAc41F>)ZM(6-s0|kPF7BLFuUNp)leIc# zA;Yn-Hw*jJ1yBZoxwsfxS3f->SJG7K#I11_O<5gqM|vQhk9*=?^cZ|G6~A}zVS2WG zl8RUvCa>O!`K>_FJx;gR8ja8bA|xyb@kHZzOLLZ#r)VZx(M3Zys+cZ#i!TZxwG1ua0*$qD4e@#F&Vw5i=uZ zN6d}jA{Ixy9(I(@vn&U5nn`HjJOu@fDial{1$u#U&&YT&HN;O z3O|kClHZ!2!*9>erB2Dj~`ll@gT} z)iSDWR7O-*R8CZE+V8M97M1fzx3Z@9A3Z@HY2xbZ9 z2<8bE2v!N!2 zhYKr&RYIR|lyHi0n(#H@Ea4oXf1YrKaHVjSaE)+_aJz7)aJO);@NMCT!jr;N!ZX6p zh3AD=gB9UU)+!6}1uNiw222q9Rd=Xt=0SR3-9>ri!MEW{75q=7^Sv>O~tw zn?##MJ46kl-J-pscST1==?3V16?3Wyr9FiQ9oRoYaxhT0NxgohF`9tz|G>m4VL!-l^#nFoB z*yvu-gQBaV$3{<%o)^6+dP(%s=#|l{qidt!j~U&q;rf0hvsumZi$N z%DTz=%I3)y%j#rr$@a;P$d1ZBkR6j9mz|cKm7SA)A-gEMEW0Zg$<6YC@)G$l`EdCt z`9!&2&dR6Ar^*+~m&xCduavKruaj?(Z<248Z)*A;6NTNS$%2NlN^=M-NkE-Ef7zEoUQe66^p_*HRR zaaZxX;(_9k;!kC~vb}Po@(tx$xgPo-0}Qx&NyRFhS&s-~%4 zQ_WP(R?St-S1nX6Qmt2QRK2CzqS~g~p=wa=QSDP5P`#u2Om$85yE;OxQajZzsk^C5 z)jst^wO`Gur>Li@=c?zc7pfPjm#CMj52#P7zf%9LiPXq6F`DKYg(g;`*BCV>jo+fN zX&jpNntmFeW}aq+W~FA8W{u{6<|EAs&8M2rG^aIZHMcbP<79C$an0j3@rm)>3KQrr)98rQf69r~gF%x&EU5vflrt{;K|G{T=;1 z{eAsI12kYmh#}0tGsGAY45@|;LkB}I!$89zLy@7xFw8LAP+=Hps5VSB%r(q6Y&0A+ zoG@H7+%rmyGGjBN!l*LF854{J#zJF}(Q7O<`iy?#RO4&LnZ`NBMaGrJgT^DqtHvLU z_l)bj z)~qpW%_g(e>@d5{$>t8`BJ)`DWb;(>Yv!5eZD#)o^DPUss4dBsJWEeYwPloLtYw15 zZ<%bFYI)5v(=x}h&a%<6*|N>D)3V#L&vL}_zU7$ZxaFMXg5{FsOUrf39m_q-1Ir_; z%-Y4;-&$-PY%Q~vTdS?3tYfVctdp!;te0(}Hl``&iL4(w5Ofn8`9+tchB_TKgZcAtHseUhEn z=h&Cnm)Td?SKI6CZ`rrnci0>3d+ht{N9-5u-`H>3A2}FDm?Odw=AwCMAzFskpta}|v<_`V+tC+j z2ReYhLiVEQq(C~fgjUcRGN2=Lh3?P?`a(a*hapf1Ltz+{!E~4bGhr6YhB+`7IQRhO!G|y( z7QjMS1dCw_EQOC?Eqns&;8R!+8(<@Bf^D!L4#1ai5Dvj%I1OjuEL?c5fUlU69X}lcw!=EVj)&io75qRq%NsX+LF$s2kA|+NgnA>^2s1Fm<%Dsq?Gsw zC4TZQd5_E@ZD*4?WG>;z$7D5GL)MZ{$U5>VSx+{Q&&cOwCpkn8lOyCSa+I7S=gBwZ zGWnj|CJ)Fj@7ke~{qNbcV`itkL7x6)Z}#g;5sjuQ&dYBle2tdNZ46g&XZZEx zuiFL$pVV?HLAvrygu%h~8r)G{k0ybUhoYjaj+tpCCAp(2*`J>0kQhmrox@N$O0GgB zs1%h^12xk4Dl{C8KqIM%Hm0f6#%Ut6x$A-yu9G{WBN~q;pyZW7x7#ttTLs*YN%v&@xw`5cm`VcKZJ?9FeQx>5m{|2ibWjR_A@TLM) zqAKd7HL0r#twgKP$J9+5(1y&L@$MKlY&)=Wez$V2EW+I_x1_*R)-vB)R#b8}7?Mv> z`wFz4CeXO^XcO9uwy-d5MW3P1(Kc?G$QV(F)~2;+eXean*5KouC#_LR_3%5<9+X^( zcA?!gk=Ct5d(l3cM3bp4D>O=Jk2l9T6d30)tw-Z}c#BHYii>k(ik|(8@;!C`ZBu~0 zW9T@yNogM1<3%+f&_%Nc!LZGhll_fv?Was5MfF?;$u9dURm8Q%2Rwh5o^$7>ZtXR6H<{ zF$x2qfacP?O7OxU+MnjL30*^(!hNS~<_nlx1YMY!$LB1aK4lg&Gw4D|uq|MEb{6eZ zjaLp7#jJ(lFak!xC>RZ6U@VM-@!+EaXaOBay>t*AOoz}yI+PZzMfKoaR1djfBD@ci zU@}aBsca<}#@?N12`!~%d|FP2(-ADHi=*oDTNDlLU*akAaJ!@Om5Q=pib?kj_xAU+ z$}RK`8O7o*^OTRvUE%ZldH>%&70+8tjDYaD$mPgU+NA=qx&W72JZ` z@B`dopSS58I+t?v1NJ#LuqefIn_}X)INj8spneSrYThcxy_d8P3vyy&t=dVo5^E6$gD9ya25Y{F)2!B&QCXFzM|SM(@7K~MTwPU+-_x{R0IahqL9xFM4P zu7{IxecXV4Lf6qx>G~Bo1vkKraTB_MuBSWb?m%D+T!JZPt|^k+sqf%O$87`sTH;o? zHEu&U(oJ+T-Le9=!|ibg+>UOgpV80Rr;V#^C=hhR-BAo%jWW3929u^2?t@|~aC`b$ z1@23?(TuZ5igR#3oXb5i#E~!Pb}rV~HT*c2zj{htZkVxQ`0+WLvb_zsjmDBlmML&P z_T$&k+_<1D@FZXsOvY2_A$pi*W)+td1xmdfnZ+}- z%v&}pCA9FTl=dI!DfGPhXL$8gO6?HF%%HSPsl}qX5mhArVlT9)os}Ie|1}qKS9ZBaRsi#Rd@wniC5u|@oKyVucfExX?lj9rRV5* zdVyY~m*_WZ@jB$f>+uG>5pTkq@fN%le?~8}y7&scO24DO_#x5{Hb2)Pz}+u&Tshw+g<9W&bw&G(G#oLe@q5`TqyzS+PT>PBm2=psIjPvDbGL#Oa*dM5Zhuef zlA`irPkx7@g3|P&!d&mr5CfM{`zrh`zJjmP>-09g!&YnjJ-*Jyyn%1xTfN)n2k0E& z^^|lE+DmWHoAg%q=H(LakbD^q`ST1Nag|A{)$D0+{wf-f*Pp5kZR8F367D)6nnq5q-Q0xk)V1uuB(A zZhz1}+4Y3YBO5o#>EiY{JDY5zCR-VZoj8b-{zaeCXH~>S+$4cM_d|pq1nk**HQUun zVwDo9M_IUJ9GSU)QDH)Eaq$pOLVj65k_pYrnOd0iSn*KC^we!svFFuXCmvG3awZwz zhw#w$DI>If(sIgLwjfi5WGI(bOC3gv7=3BARCyu$hXr?5)i7m*<+>H5oD3%;$Vfj3 z{UGv#*bkBwWHcEAx5+p^Nc|8+=lY=rwe{@a8Bo^4>lx9jtG8fa@K^5+xuc58%QDzD zh1*|CU9TE?0+|wko=Dy&lgMO0$owGpgTfEW708WTXcnr+Xz$1p6x%>^rrP7Xk`KsI zwn&nBjxcMEQ1$YCPNp43tWK;EOgnn ztgSN~eAO0hB%4_wN;df+u7YgwvjUy1Vsa+-%;F*5pf7>BUmA+jHkPN!cJhTE41Tb@ z*7q*5j}_5mH`zn>`oZXjct4n`NI9xUzGU&E#eOieil3oY=KwC+7Vk5qjxxNNb;;M{ zp+TKajg`8~Yv``zJrR0%N;rMlUA8(C*ZfIz9DYR{TmHzYX9IHoKqrp;n-vs7;w7 zr3@n7lUH8gEbx@1m3VSZsl9?g{}}&*ybKu6rek6S6GT7?yX#uDVPO$$DGTF;h5I4N z5A`Yo3Cjqs7_^=+HIS|BFlam3L3S}V_mKnSAUhViLH=e3KZ#+z!-j;t z8@4QLOW4<8FL+X(l~;?G!fVWH%1h<>n)A|mt$3NdKD+{+mp7PK$lJu*!8^n|$ve-x z%)80^h4(A(ci!XhhT*Nk+l03Z?+`vad};Wy@QU!N@E^k;h5r%$B>ZVa$B3+moQV90 zVG$!EMn{Z|7#}e)Vp7DEh-ncsBDO``=Ue!R{3iTn{4{LYqbG(c1;8Yvnr z8Y}XP-V;p}O%lx(aiV#m`J#2A{i2hiQ=-$Nv!d@sH$}HaKZ@>(?u-5sBe6g%5zEAK zu|`}&tQQ-_3F129y5eMU196JDg}9ZtjkujSQ=BLEiieBGiG5rx(y5u*>Us5cUN~5GPQnj>(R4+A3P11Lyi=)EskoTBcg3TCduu+N|2D`dqbLbx3tY zbyRgsbwYJYbw+hg^+a7m-C8|NJz70hJyAVVJzKp>y+OTEy;;3ieOi51eO`T0{f+ut z^;PvX^>y{H>fhCm)qkr0@~NMxU#MTkLM(~Z#yVqr$G#VPF!qiHYiekkX*y_fG`X7o z8jq$x zC)9~`W?cha7u^uuDBWn?7~MGC0^Lg8YTa7hr@9TgO}ZVrOS;RtD>ZJ`xE~i4r;SUG zTNC$r+|Ib&aeLzq#vP9PD(>sJ-}KS?c)eR+OJ7G{S6@$GU*BAxu5YDpqi?70pzo+J z)z8we)F0E|^y!}(h=Fg2G>8lmLyV!Rp|zonp`9Vq(9hs86d1gQp@w2ZiJ{E!k>P;h znBlnLq~Vg`w&5?sGs6qROCuPGk!OrB3XDRd*cfj#8?8pW(P?xU6O46?Nyhrd6k}s! zj&ZtigK>xPy75W8B)(33YJ9i&-1zeN$??9a@m&0}_!aRR;}69jiN7BIy9t{#Cby}r zsl6$~)XCJvlxga2nqr!3T4-8qT54KmsxYlLePP;b+Hd;ObjWnfbi#De^w{*&9Ba0k z?PjOhWv*>*Vs2(mGq*6eGiRFnne)u~<^kp+^F;Gh^K$bV^H%fc=I!Q3<`))$&myzv zED4tSmOM*=Wv*qh*yZ*p zdyL&=x7clVhdtTez@B1nY;S9CZ_luIviG$6?9=VD>~rn&?DOpl?Vs7N+JCS=cYs6f zFgoHLW=9=Iild1m)zQMy%F)Tu)zRIN<>>9`>+m>=9HSj`eU4?0)s9ab>m8dMTOGR{ zdmZ~7UpfvsjyQgBVyDH~)Y;zI*O}wYb9$Tuor9f2ox_}E&WX-R&MD4m&Kb^G&N)ub zInO!YdC2*=W_-;qHOJOmSM!=n?9#YuxC}0n%j&Yb8oOG$+PN}Zon2jBIj#XNuWN{_ z$Ti$G%H)aN%)9rTGb|<=B;Y M#G&u%&x9`j2N+ADB>(^b 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 94ffe1757..2204bc3f1 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 @@ -22,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 @@ -64,6 +66,16 @@ class SettingsViewModelTest { 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, mockBiometricInteractor) 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 d052e8dd2..0eefa32b5 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 @@ -49,7 +49,7 @@ class SignInViewModelTest { @Test fun showSignInForm() = runTest { signInViewModel.stateFlow.test { - assertEquals(SignInResult.ShowSignInForm, awaitItem()) + assertEquals(SignInResult(), awaitItem()) cancelAndIgnoreRemainingEvents() } } @@ -57,7 +57,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) @@ -69,7 +69,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) @@ -84,10 +84,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.State.ShowEmptyPassError, awaitItem().state) cancelAndIgnoreRemainingEvents() } @@ -96,12 +96,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.State.ShowIncorrectPassError, awaitItem().state) cancelAndIgnoreRemainingEvents() } @@ -110,7 +110,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) @@ -127,10 +127,10 @@ class SignInViewModelTest { fun refreshBiometricVisibleWhenAvailable() = runTest { Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(true) Mockito.`when`(mockBiometricInteractor.canAuthenticate()).thenReturn(true) - signInViewModel.biometricVisibleFlow.test { - assertFalse(awaitItem()) + signInViewModel.stateFlow.test { + assertFalse(awaitItem().biometricVisible) signInViewModel.onAction(SignInAction.RefreshBiometric) - assertTrue(awaitItem()) + assertTrue(awaitItem().biometricVisible) cancelAndIgnoreRemainingEvents() } } @@ -142,7 +142,7 @@ class SignInViewModelTest { .thenReturn(DecryptedPasswordResult.Success(pass)) Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true) signInViewModel.stateFlow.test { - assertEquals(SignInResult.ShowSignInForm, awaitItem()) + assertEquals(SignInResult(), awaitItem()) signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c")) Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Main) cancelAndIgnoreRemainingEvents() @@ -153,11 +153,11 @@ class SignInViewModelTest { fun biometricSignInUnavailableClearsState() = runTest { Mockito.`when`(mockBiometricInteractor.decryptStoredPassword(anyObject(), anyObject(), anyObject())) .thenReturn(DecryptedPasswordResult.Failure(BiometricResult.Unavailable)) - signInViewModel.biometricVisibleFlow.test { - assertFalse(awaitItem()) + signInViewModel.stateFlow.test { + assertFalse(awaitItem().biometricVisible) signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c")) Mockito.verify(mockBiometricInteractor).clearStoredPassword() cancelAndIgnoreRemainingEvents() } } -} \ No newline at end of file +} diff --git a/core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricActivityHolder.kt b/core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricActivityHolder.kt deleted file mode 100644 index 4728063ca..000000000 --- a/core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricActivityHolder.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.softartdev.notedelight.interactor - -import androidx.appcompat.app.AppCompatActivity -import java.lang.ref.WeakReference - -class BiometricActivityHolder { - private var ref: WeakReference? = null - - fun attach(activity: AppCompatActivity) { - ref = WeakReference(activity) - } - - fun detach() { - ref?.clear() - ref = null - } - - fun current(): AppCompatActivity? = ref?.get() -} 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 index 1bb45504c..ccede1dda 100644 --- 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 @@ -1,38 +1,42 @@ package com.softartdev.notedelight.interactor +import android.app.Activity +import android.app.Application import android.content.Context import android.content.SharedPreferences +import android.os.Build +import android.os.Bundle import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.KeyProperties import android.util.Base64 -import androidx.appcompat.app.AppCompatActivity import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat +import androidx.core.content.edit +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 -import androidx.core.content.edit -actual class BiometricInteractor( - private val context: Context, - private val activityHolder: BiometricActivityHolder, -) { +actual class BiometricInteractor(context: Context) { private val logger = Logger.withTag("BiometricInteractor") + private val appContext: Context = context.applicationContext + private val activityProvider = CurrentActivityProvider(appContext as Application) private val prefs: SharedPreferences = - context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - actual suspend fun canAuthenticate(): Boolean { - val bm = BiometricManager.from(context) - return bm.canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS - } + actual suspend fun canAuthenticate(): Boolean = BiometricManager + .from(appContext) + .canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS actual fun hasStoredPassword(): Boolean = prefs.contains(KEY_CIPHERTEXT) && prefs.contains(KEY_IV) @@ -43,21 +47,21 @@ actual class BiometricInteractor( subtitle: String, negativeButton: String, ): BiometricResult { - val activity = activityHolder.current() + val activity: FragmentActivity = activityProvider.current ?: return BiometricResult.Error("No active Activity for BiometricPrompt") clearStoredPassword() - val secretKey = try { + val secretKey: SecretKey = try { createOrGetKey() } catch (t: Throwable) { logger.e(t) { "Keystore failure" } return BiometricResult.Error(t.message ?: "Keystore failure") } - val cipher = Cipher.getInstance(TRANSFORMATION).apply { + val cipher: Cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, secretKey) } - return when (val auth = runPrompt(activity, cipher, title, subtitle, negativeButton)) { + return when (val auth: PromptOutcome = runPrompt(activity, cipher, title, subtitle, negativeButton)) { is PromptOutcome.Authenticated -> { - val out = auth.cipher.doFinal(password.toString().toByteArray(Charsets.UTF_8)) + val out: ByteArray = auth.cipher.doFinal(password.toString().toByteArray(Charsets.UTF_8)) prefs.edit { putString(KEY_CIPHERTEXT, Base64.encodeToString(out, Base64.NO_WRAP)) putString(KEY_IV, Base64.encodeToString(auth.cipher.iv, Base64.NO_WRAP)) @@ -76,11 +80,12 @@ actual class BiometricInteractor( if (!hasStoredPassword()) { return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) } - val activity = activityHolder.current() ?: return DecryptedPasswordResult.Failure( - result = BiometricResult.Error("No active Activity for BiometricPrompt") - ) - val ciphertext: ByteArray? = Base64.decode(prefs.getString(KEY_CIPHERTEXT, null), Base64.NO_WRAP) - val iv: ByteArray? = Base64.decode(prefs.getString(KEY_IV, null), Base64.NO_WRAP) + val activity: FragmentActivity = activityProvider.current + ?: return DecryptedPasswordResult.Failure( + result = BiometricResult.Error("No active Activity for BiometricPrompt") + ) + val ciphertext: ByteArray = Base64.decode(prefs.getString(KEY_CIPHERTEXT, null), Base64.NO_WRAP) + val iv: ByteArray = Base64.decode(prefs.getString(KEY_IV, null), Base64.NO_WRAP) val secretKey: SecretKey = try { existingKey() ?: run { clearStoredPassword() @@ -125,77 +130,79 @@ actual class BiometricInteractor( remove(KEY_IV) } runCatching { - val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) - keyStore.load(null) - keyStore.deleteEntry(KEY_ALIAS) + KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }.deleteEntry(KEY_ALIAS) } } private fun existingKey(): SecretKey? { - val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) - keyStore.load(null) + 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 generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) - val spec = KeyGenParameterSpec + 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(true) - .build() - generator.init(spec) + // 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: AppCompatActivity, + activity: FragmentActivity, cipher: Cipher, title: String, subtitle: String, negativeButton: String, - ): PromptOutcome = suspendCancellableCoroutine { continuation -> - val executor = ContextCompat.getMainExecutor(context) - val callback = object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - val resultCipher = result.cryptoObject?.cipher - if (resultCipher == null) { - continuation.resume( + ): 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 { - continuation.resume(PromptOutcome.Authenticated(resultCipher)) + } else { + PromptOutcome.Authenticated(resultCipher) + } + if (continuation.isActive) continuation.resume(outcome) } - } - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - val mapped = 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()) + 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. } - continuation.resume(PromptOutcome.Failure(mapped)) - } - override fun onAuthenticationFailed() { - // Triggered on a wrong fingerprint; system gives the user another try, so do not - // resume the continuation here. The terminal callback is onAuthenticationError. } + 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)) } - val prompt = BiometricPrompt(activity, executor, callback) - val info = BiometricPrompt.PromptInfo.Builder() - .setTitle(title) - .setSubtitle(subtitle) - .setNegativeButtonText(negativeButton) - .setAllowedAuthenticators(BIOMETRIC_STRONG) - .build() - prompt.authenticate(info, BiometricPrompt.CryptoObject(cipher)) - continuation.invokeOnCancellation { runCatching { prompt.cancelAuthentication() } } } private sealed interface PromptOutcome { @@ -203,6 +210,32 @@ actual class BiometricInteractor( data class Failure(val result: BiometricResult) : PromptOutcome } + private class CurrentActivityProvider(application: Application) : Application.ActivityLifecycleCallbacks { + var current: FragmentActivity? = null + private set + + init { + application.registerActivityLifecycleCallbacks(this) + } + + override fun onActivityResumed(activity: Activity) { + current = activity as? FragmentActivity + } + + override fun onActivityPaused(activity: Activity) { + if (activity === current) current = null + } + + override fun onActivityDestroyed(activity: Activity) { + if (activity === current) current = null + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + override fun onActivityStarted(activity: Activity) = Unit + override fun onActivityStopped(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + } + companion object { private const val ANDROID_KEYSTORE = "AndroidKeyStore" private const val KEY_ALIAS = "notedelight_biometric_key" 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 17573ef14..c93fde983 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,9 +1,17 @@ package com.softartdev.notedelight.presentation.signin -enum class SignInResult(val isError: Boolean = false) { - ShowSignInForm, - ShowProgress, - ShowEmptyPassError(isError = true), - ShowIncorrectPassError(isError = true), - ShowBiometricError(isError = true), +data class SignInResult( + val state: State = State.ShowSignInForm, + val biometricVisible: Boolean = false, +) { + val isError: Boolean + get() = state.isError + + enum class State(val isError: Boolean = false) { + ShowSignInForm, + ShowProgress, + ShowEmptyPassError(isError = true), + ShowIncorrectPassError(isError = true), + ShowBiometricError(isError = true), + } } 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 70919d449..299d2d11a 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 @@ -5,29 +5,26 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import com.softartdev.notedelight.interactor.BiometricInteractor -import com.softartdev.notedelight.interactor.DecryptedPasswordResult import com.softartdev.notedelight.interactor.BiometricResult +import com.softartdev.notedelight.interactor.DecryptedPasswordResult 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 biometricInteractor: BiometricInteractor, - private val router: Router + private val router: Router, ) : ViewModel() { private val logger = Logger.withTag(this@SignInViewModel::class.simpleName.toString()) - private val mutableStateFlow: MutableStateFlow = MutableStateFlow( - value = SignInResult.ShowSignInForm - ) - val stateFlow: StateFlow = mutableStateFlow - private val mutableBiometricVisibleFlow: MutableStateFlow = MutableStateFlow(false) - val biometricVisibleFlow: StateFlow = mutableBiometricVisibleFlow + private val mutableStateFlow: MutableStateFlow = MutableStateFlow(SignInResult()) + val stateFlow: StateFlow = mutableStateFlow var autofillManager: AutofillManager? = null @@ -38,34 +35,42 @@ class SignInViewModel( is SignInAction.OnBiometricClick -> signInWithBiometric( title = action.title, subtitle = action.subtitle, - negativeButton = action.negativeButton + negativeButton = action.negativeButton, ) } private fun refreshBiometric() = viewModelScope.launch { - mutableBiometricVisibleFlow.value = biometricInteractor.hasStoredPassword() && biometricInteractor.canAuthenticate() + val visible: Boolean = biometricInteractor.hasStoredPassword() && biometricInteractor.canAuthenticate() + mutableStateFlow.update { it.copy(biometricVisible = visible) } } private fun signInWithBiometric(title: String, subtitle: String, negativeButton: String) = viewModelScope.launch { CountingIdlingRes.increment() - mutableStateFlow.value = SignInResult.ShowProgress + mutableStateFlow.update { it.copy(state = SignInResult.State.ShowProgress) } try { when (val res: DecryptedPasswordResult = biometricInteractor.decryptStoredPassword(title, subtitle, negativeButton)) { - is DecryptedPasswordResult.Success -> signInInternal(res.password) + is DecryptedPasswordResult.Success -> mutableStateFlow.update { + it.copy(state = signInInternal(res.password)) + } is DecryptedPasswordResult.Failure -> when (res.result) { - BiometricResult.Cancelled -> mutableStateFlow.value = SignInResult.ShowSignInForm + BiometricResult.Cancelled -> mutableStateFlow.update { + it.copy(state = SignInResult.State.ShowSignInForm) + } BiometricResult.Unavailable -> { biometricInteractor.clearStoredPassword() - mutableBiometricVisibleFlow.value = false - mutableStateFlow.value = SignInResult.ShowSignInForm + mutableStateFlow.update { + it.copy(state = SignInResult.State.ShowSignInForm, biometricVisible = false) + } + } + else -> mutableStateFlow.update { + it.copy(state = SignInResult.State.ShowBiometricError) } - else -> mutableStateFlow.value = SignInResult.ShowBiometricError } } } catch (error: Throwable) { logger.e(error) { "Error during biometric sign in" } router.navigate(route = AppNavGraph.ErrorDialog(message = error.message)) - mutableStateFlow.value = SignInResult.ShowSignInForm + mutableStateFlow.update { it.copy(state = SignInResult.State.ShowSignInForm) } } finally { CountingIdlingRes.decrement() } @@ -73,26 +78,27 @@ class SignInViewModel( private fun signIn(pass: CharSequence) = viewModelScope.launch { CountingIdlingRes.increment() - mutableStateFlow.value = SignInResult.ShowProgress + mutableStateFlow.update { it.copy(state = SignInResult.State.ShowProgress) } try { - mutableStateFlow.value = signInInternal(pass) + val nextState: SignInResult.State = signInInternal(pass) + mutableStateFlow.update { it.copy(state = nextState) } } catch (error: Throwable) { logger.e(error) { "Error during sign in" } autofillManager?.cancel() router.navigate(route = AppNavGraph.ErrorDialog(message = error.message)) - mutableStateFlow.value = SignInResult.ShowSignInForm + mutableStateFlow.update { it.copy(state = SignInResult.State.ShowSignInForm) } } finally { CountingIdlingRes.decrement() } } - private suspend fun signInInternal(pass: CharSequence): SignInResult = when { - pass.isEmpty() -> SignInResult.ShowEmptyPassError + private suspend fun signInInternal(pass: CharSequence): SignInResult.State = when { + pass.isEmpty() -> SignInResult.State.ShowEmptyPassError checkPasswordUseCase(pass) -> { autofillManager?.commit() router.navigateClearingBackStack(AppNavGraph.Main) - SignInResult.ShowSignInForm + SignInResult.State.ShowSignInForm } - else -> SignInResult.ShowIncorrectPassError + else -> SignInResult.State.ShowIncorrectPassError } } 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 054eb6041..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,7 +1,6 @@ package com.softartdev.notedelight.di import com.softartdev.notedelight.interactor.AdaptiveInteractor -import com.softartdev.notedelight.interactor.BiometricActivityHolder import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.LocaleInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor @@ -14,6 +13,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) - singleOf(::BiometricActivityHolder) singleOf(::BiometricInteractor) } \ No newline at end of file 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 cc411d7a6..75a875f23 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 @@ -61,8 +61,6 @@ import org.jetbrains.compose.resources.stringResource @Composable fun SignInScreen(signInViewModel: SignInViewModel) { val signInResultState: State = signInViewModel.stateFlow.collectAsState() - val biometricVisibleState: State = - signInViewModel.biometricVisibleFlow.collectAsState() val passwordState: MutableState = remember { mutableStateOf("") } val autofillManager: AutofillManager? = LocalAutofillManager.current LaunchedEffect(key1 = signInViewModel, key2 = autofillManager) { @@ -71,18 +69,19 @@ fun SignInScreen(signInViewModel: SignInViewModel) { LaunchedEffect(signInViewModel) { signInViewModel.onAction(SignInAction.RefreshBiometric) } + val result: SignInResult = signInResultState.value SignInScreenBody( - showLoading = signInResultState.value == SignInResult.ShowProgress, + showLoading = result.state == SignInResult.State.ShowProgress, passwordState = passwordState, - labelResource = when (signInResultState.value) { - SignInResult.ShowEmptyPassError -> Res.string.empty_password - SignInResult.ShowIncorrectPassError -> Res.string.incorrect_password - SignInResult.ShowBiometricError -> Res.string.biometric_error + labelResource = when (result.state) { + SignInResult.State.ShowEmptyPassError -> Res.string.empty_password + SignInResult.State.ShowIncorrectPassError -> Res.string.incorrect_password + SignInResult.State.ShowBiometricError -> Res.string.biometric_error else -> Res.string.enter_password }, - isError = signInResultState.value.isError, - biometricVisible = biometricVisibleState.value, - onAction = signInViewModel::onAction + isError = result.isError, + biometricVisible = result.biometricVisible, + onAction = signInViewModel::onAction, ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 046302c2b..3bf1a08eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,7 @@ composeMaterial3 = "1.9.0" composeMaterialIconsExtended = "1.7.3" composeMaterialAdaptive = "1.2.0" androidxAppcompat = "1.7.1" -androidxBiometric = "1.2.0-alpha05" +androidxBiometric = "1.1.0" androidxViewModel = "2.10.0" androidxNavigation = "2.9.2" androidxNavigationEvent = "1.0.1" @@ -121,7 +121,7 @@ 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-ktx", version.ref = "androidxBiometric" } +androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "androidxBiometric" } 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" } From 815fb3e5ee6c9deefaaefd6cfaaad659a84c77b7 Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Wed, 29 Apr 2026 01:32:55 +0400 Subject: [PATCH 04/13] refactor: improve sign-in state model and biometric activity tracking - Refactor `SignInResult` from a data class with an enum-based state to a sealed interface hierarchy for improved type safety and state handling. - Update `SignInViewModel` and `SignInScreen` to utilize the new sealed structure and implement a `setState` helper for atomic updates. - Extract `CurrentActivityProvider` into a standalone internal class, utilizing `WeakReference` to prevent potential memory leaks of the host `FragmentActivity`. - Enhance error handling in `BiometricEnrollViewModel` to extract and display specific error messages from `BiometricResult`. - Remove the deprecated `USE_FINGERPRINT` permission from the Android manifest. - Introduce a `dispose()` method in `BiometricInteractor` for explicit lifecycle management and cleaner test execution. - Update `SignInViewModelTest` to align assertions with the new sealed state model. --- app/android/src/main/AndroidManifest.xml | 1 - .../signin/SignInViewModelTest.kt | 18 ++--- .../interactor/BiometricInteractor.android.kt | 31 +-------- .../interactor/CurrentActivityProvider.kt | 66 +++++++++++++++++++ .../biometric/BiometricEnrollViewModel.kt | 11 +++- .../presentation/signin/SignInResult.kt | 28 ++++---- .../presentation/signin/SignInViewModel.kt | 44 ++++++------- .../notedelight/ui/signin/SignInScreen.kt | 15 ++--- 8 files changed, 128 insertions(+), 86 deletions(-) create mode 100644 core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/CurrentActivityProvider.kt diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml index 24c848f17..eee8352ab 100644 --- a/app/android/src/main/AndroidManifest.xml +++ b/app/android/src/main/AndroidManifest.xml @@ -2,7 +2,6 @@ - ? = null + + val current: FragmentActivity? + get() = ref?.get() + + init { + application.registerActivityLifecycleCallbacks(this) + } + + fun dispose() { + application.unregisterActivityLifecycleCallbacks(this) + ref?.clear() + ref = null + } + + override fun onActivityResumed(activity: Activity) { + logger.i { "onActivityResumed: ${activity::class.java.simpleName}" } + if (activity is FragmentActivity) ref = WeakReference(activity) + } + + override fun onActivityPaused(activity: Activity) { + logger.i { "onActivityPaused: ${activity::class.java.simpleName}" } + if (ref?.get() === activity) ref = null + } + + override fun onActivityDestroyed(activity: Activity) { + logger.i { "onActivityDestroyed: ${activity::class.java.simpleName}" } + if (ref?.get() === activity) ref = null + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + logger.i { "onActivityCreated: ${activity::class.java.simpleName}" } + } + + override fun onActivityStarted(activity: Activity) { + logger.i { "onActivityStarted: ${activity::class.java.simpleName}" } + } + override fun onActivityStopped(activity: Activity) { + logger.i { "onActivityStopped: ${activity::class.java.simpleName}" } + } + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { + logger.i { "onActivitySaveInstanceState: ${activity::class.java.simpleName}" } + } +} diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt index 09e072529..43ba30b3a 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt @@ -79,9 +79,14 @@ class BiometricEnrollViewModel( is BiometricResult.Success -> withContext(coroutineDispatchers.main) { router.popBackStack() } - else -> snackbarInteractor.showMessage( - message = SnackbarMessage.Simple(result.toString()) - ) + else -> { + val resultMessage: String = when (result) { + is BiometricResult.Error -> result.message + else -> result.toString() + } + logger.e { resultMessage } + snackbarInteractor.showMessage(SnackbarMessage.Simple(resultMessage)) + } } } else -> { 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 c93fde983..485b636b6 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,17 +1,19 @@ package com.softartdev.notedelight.presentation.signin -data class SignInResult( - val state: State = State.ShowSignInForm, - val biometricVisible: Boolean = false, -) { - val isError: Boolean - get() = state.isError - - enum class State(val isError: Boolean = false) { - ShowSignInForm, - ShowProgress, - ShowEmptyPassError(isError = true), - ShowIncorrectPassError(isError = true), - ShowBiometricError(isError = true), +sealed interface SignInResult { + + val biometricVisible: Boolean + + data class Form(override val biometricVisible: Boolean = false) : SignInResult + + data class Progress(override val biometricVisible: Boolean = false) : SignInResult + + sealed interface Error : SignInResult { + + data class EmptyPass(override val biometricVisible: Boolean = false) : Error + + data class IncorrectPass(override val biometricVisible: Boolean = false) : Error + + data class Biometric(override val biometricVisible: Boolean = false) : Error } } 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 299d2d11a..c3a1c3b96 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 @@ -23,7 +23,7 @@ class SignInViewModel( ) : ViewModel() { private val logger = Logger.withTag(this@SignInViewModel::class.simpleName.toString()) - private val mutableStateFlow: MutableStateFlow = MutableStateFlow(SignInResult()) + private val mutableStateFlow: MutableStateFlow = MutableStateFlow(SignInResult.Form()) val stateFlow: StateFlow = mutableStateFlow var autofillManager: AutofillManager? = null @@ -41,36 +41,28 @@ class SignInViewModel( private fun refreshBiometric() = viewModelScope.launch { val visible: Boolean = biometricInteractor.hasStoredPassword() && biometricInteractor.canAuthenticate() - mutableStateFlow.update { it.copy(biometricVisible = visible) } + mutableStateFlow.value = SignInResult.Form(biometricVisible = visible) } private fun signInWithBiometric(title: String, subtitle: String, negativeButton: String) = viewModelScope.launch { CountingIdlingRes.increment() - mutableStateFlow.update { it.copy(state = SignInResult.State.ShowProgress) } + setState { SignInResult.Progress(it) } try { when (val res: DecryptedPasswordResult = biometricInteractor.decryptStoredPassword(title, subtitle, negativeButton)) { - is DecryptedPasswordResult.Success -> mutableStateFlow.update { - it.copy(state = signInInternal(res.password)) - } + is DecryptedPasswordResult.Success -> setState { signInInternal(res.password, it) } is DecryptedPasswordResult.Failure -> when (res.result) { - BiometricResult.Cancelled -> mutableStateFlow.update { - it.copy(state = SignInResult.State.ShowSignInForm) - } + BiometricResult.Cancelled -> setState { SignInResult.Form(it) } BiometricResult.Unavailable -> { biometricInteractor.clearStoredPassword() - mutableStateFlow.update { - it.copy(state = SignInResult.State.ShowSignInForm, biometricVisible = false) - } - } - else -> mutableStateFlow.update { - it.copy(state = SignInResult.State.ShowBiometricError) + mutableStateFlow.value = SignInResult.Form(biometricVisible = false) } + else -> setState { SignInResult.Error.Biometric(it) } } } } catch (error: Throwable) { logger.e(error) { "Error during biometric sign in" } router.navigate(route = AppNavGraph.ErrorDialog(message = error.message)) - mutableStateFlow.update { it.copy(state = SignInResult.State.ShowSignInForm) } + setState { SignInResult.Form(it) } } finally { CountingIdlingRes.decrement() } @@ -78,27 +70,31 @@ class SignInViewModel( private fun signIn(pass: CharSequence) = viewModelScope.launch { CountingIdlingRes.increment() - mutableStateFlow.update { it.copy(state = SignInResult.State.ShowProgress) } + setState { SignInResult.Progress(it) } try { - val nextState: SignInResult.State = signInInternal(pass) - mutableStateFlow.update { it.copy(state = nextState) } + setState { signInInternal(pass, it) } } catch (error: Throwable) { logger.e(error) { "Error during sign in" } autofillManager?.cancel() router.navigate(route = AppNavGraph.ErrorDialog(message = error.message)) - mutableStateFlow.update { it.copy(state = SignInResult.State.ShowSignInForm) } + setState { SignInResult.Form(it) } } finally { CountingIdlingRes.decrement() } } - private suspend fun signInInternal(pass: CharSequence): SignInResult.State = when { - pass.isEmpty() -> SignInResult.State.ShowEmptyPassError + private suspend fun signInInternal(pass: CharSequence, biometricVisible: Boolean): SignInResult = when { + pass.isEmpty() -> SignInResult.Error.EmptyPass(biometricVisible) checkPasswordUseCase(pass) -> { autofillManager?.commit() router.navigateClearingBackStack(AppNavGraph.Main) - SignInResult.State.ShowSignInForm + SignInResult.Form(biometricVisible) } - else -> SignInResult.State.ShowIncorrectPassError + else -> SignInResult.Error.IncorrectPass(biometricVisible) + } + + // Atomically rewrites the SignInResult while preserving the current biometricVisible flag. + private inline fun setState(transform: (biometricVisible: Boolean) -> SignInResult) { + mutableStateFlow.update { transform(it.biometricVisible) } } } 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 75a875f23..f78b1c1a8 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 @@ -69,18 +69,17 @@ fun SignInScreen(signInViewModel: SignInViewModel) { LaunchedEffect(signInViewModel) { signInViewModel.onAction(SignInAction.RefreshBiometric) } - val result: SignInResult = signInResultState.value SignInScreenBody( - showLoading = result.state == SignInResult.State.ShowProgress, + showLoading = signInResultState.value is SignInResult.Progress, passwordState = passwordState, - labelResource = when (result.state) { - SignInResult.State.ShowEmptyPassError -> Res.string.empty_password - SignInResult.State.ShowIncorrectPassError -> Res.string.incorrect_password - SignInResult.State.ShowBiometricError -> Res.string.biometric_error + labelResource = when (signInResultState.value) { + is SignInResult.Error.EmptyPass -> Res.string.empty_password + is SignInResult.Error.IncorrectPass -> Res.string.incorrect_password + is SignInResult.Error.Biometric -> Res.string.biometric_error else -> Res.string.enter_password }, - isError = result.isError, - biometricVisible = result.biometricVisible, + isError = signInResultState.value is SignInResult.Error, + biometricVisible = signInResultState.value.biometricVisible, onAction = signInViewModel::onAction, ) } From 78c3ce1dc10fad9b2429a59f2e83ec02d0ae57e9 Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Wed, 29 Apr 2026 10:15:02 +0400 Subject: [PATCH 05/13] refactor: improve sign-in state model and activity lifecycle tracking - Refactor `SignInResult` from a sealed interface to a data class containing a nested `State` interface. This separates the primary UI state (Form, Progress, Error) from the `biometricVisible` flag. - Update `SignInViewModel` to use `MutableStateFlow.update()` for state transitions, ensuring atomic updates to the sign-in state and biometric visibility. - Adjust `SignInScreen` and `SignInViewModelTest` to access the nested state property within the new `SignInResult` model. - Enhance `CurrentActivityProvider` to capture `FragmentActivity` references during `onActivityCreated` and `onActivityStarted`, in addition to `onActivityResumed`. - Modify `CurrentActivityProvider` to only clear the activity reference during `onActivityDestroyed`, ensuring a stable host for components like `BiometricPrompt` during transient lifecycle changes. - Remove the internal helper `setState` function in `SignInViewModel` in favor of standard `StateFlow` update patterns. --- .../signin/SignInViewModelTest.kt | 18 +++---- .../interactor/CurrentActivityProvider.kt | 54 +++++++++++-------- .../presentation/signin/SignInResult.kt | 23 ++++---- .../presentation/signin/SignInViewModel.kt | 44 ++++++++------- .../notedelight/ui/signin/SignInScreen.kt | 12 ++--- 5 files changed, 84 insertions(+), 67 deletions(-) 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 1b3bd3cfe..21f791a48 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 @@ -49,7 +49,7 @@ class SignInViewModelTest { @Test fun showSignInForm() = runTest { signInViewModel.stateFlow.test { - assertEquals(SignInResult.Form(), awaitItem()) + assertEquals(SignInResult(), awaitItem()) cancelAndIgnoreRemainingEvents() } } @@ -57,7 +57,7 @@ class SignInViewModelTest { @Test fun onSettingsClick() = runTest { signInViewModel.stateFlow.test { - assertEquals(SignInResult.Form(), awaitItem()) + assertEquals(SignInResult(), awaitItem()) signInViewModel.onAction(SignInAction.OnSettingsClick) Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Settings) @@ -69,7 +69,7 @@ class SignInViewModelTest { @Test fun navMain() = runTest { signInViewModel.stateFlow.test { - assertEquals(SignInResult.Form(), awaitItem()) + assertEquals(SignInResult(), awaitItem()) val pass = StubEditable("pass") Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true) @@ -84,10 +84,10 @@ class SignInViewModelTest { @Test fun showEmptyPassError() = runTest { signInViewModel.stateFlow.test { - assertEquals(SignInResult.Form(), awaitItem()) + assertEquals(SignInResult(), awaitItem()) signInViewModel.onAction(SignInAction.OnSignInClick(pass = StubEditable(""))) - assertTrue(awaitItem() is SignInResult.Error.EmptyPass) + assertTrue(awaitItem().state is SignInResult.State.Error.EmptyPass) cancelAndIgnoreRemainingEvents() } @@ -96,12 +96,12 @@ class SignInViewModelTest { @Test fun showIncorrectPassError() = runTest { signInViewModel.stateFlow.test { - assertEquals(SignInResult.Form(), awaitItem()) + assertEquals(SignInResult(), awaitItem()) val pass = StubEditable("pass") Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(false) signInViewModel.onAction(SignInAction.OnSignInClick(pass)) - assertTrue(awaitItem() is SignInResult.Error.IncorrectPass) + assertTrue(awaitItem().state is SignInResult.State.Error.IncorrectPass) cancelAndIgnoreRemainingEvents() } @@ -110,7 +110,7 @@ class SignInViewModelTest { @Test fun showError() = runTest { signInViewModel.stateFlow.test { - assertEquals(SignInResult.Form(), awaitItem()) + assertEquals(SignInResult(), awaitItem()) val throwable = Throwable() Mockito.`when`(mockCheckPasswordUseCase(anyObject())).thenThrow(throwable) @@ -142,7 +142,7 @@ class SignInViewModelTest { .thenReturn(DecryptedPasswordResult.Success(pass)) Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true) signInViewModel.stateFlow.test { - assertEquals(SignInResult.Form(), awaitItem()) + assertEquals(SignInResult(), awaitItem()) signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c")) Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Main) cancelAndIgnoreRemainingEvents() diff --git a/core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/CurrentActivityProvider.kt b/core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/CurrentActivityProvider.kt index 1dfe340e0..90826e5e4 100644 --- a/core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/CurrentActivityProvider.kt +++ b/core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/CurrentActivityProvider.kt @@ -8,16 +8,19 @@ import co.touchlab.kermit.Logger import java.lang.ref.WeakReference /** - * Tracks the currently `RESUMED` `FragmentActivity` for app-scoped components that need an - * Activity host (e.g. `BiometricPrompt`). Holds the Activity in a `WeakReference` so a paused - * Activity awaiting GC cannot keep its window alive through this provider. + * Tracks the latest live `FragmentActivity` for app-scoped components that need an Activity host + * (e.g. `BiometricPrompt`). The reference is set during creation/start/resume — the three early + * lifecycle callbacks that are guaranteed to fire while the Activity is still live — and cleared on + * destruction. We deliberately do **not** clear in `onActivityPaused`/`onActivityStopped` so that a + * brief pause (overlay dialog, configuration change in flight) does not blank the host. * - * Registered as an [Application.ActivityLifecycleCallbacks] for the whole process at construction; - * call [dispose] to unregister (mainly useful for tests — singletons normally live for the lifetime - * of the process and the framework drops the registration when the process dies). + * The Activity is held weakly so a destroyed instance pending GC cannot keep its window leaked + * through this provider. Registered as an [Application.ActivityLifecycleCallbacks] for the whole + * process at construction; call [dispose] to unregister (mainly useful for tests — singletons + * normally live for the lifetime of the process). */ internal class CurrentActivityProvider( - private val application: Application + private val application: Application, ) : Application.ActivityLifecycleCallbacks { private val logger = Logger.withTag("CurrentActivityProvider") private var ref: WeakReference? = null @@ -35,32 +38,41 @@ internal class CurrentActivityProvider( ref = null } - override fun onActivityResumed(activity: Activity) { - logger.i { "onActivityResumed: ${activity::class.java.simpleName}" } - if (activity is FragmentActivity) ref = WeakReference(activity) + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + logger.i { "onActivityCreated: ${activity::class.java.simpleName}" } + captureIfFragmentActivity(activity) } - override fun onActivityPaused(activity: Activity) { - logger.i { "onActivityPaused: ${activity::class.java.simpleName}" } - if (ref?.get() === activity) ref = null + override fun onActivityStarted(activity: Activity) { + logger.i { "onActivityStarted: ${activity::class.java.simpleName}" } + captureIfFragmentActivity(activity) } - override fun onActivityDestroyed(activity: Activity) { - logger.i { "onActivityDestroyed: ${activity::class.java.simpleName}" } - if (ref?.get() === activity) ref = null + override fun onActivityResumed(activity: Activity) { + logger.i { "onActivityResumed: ${activity::class.java.simpleName}" } + captureIfFragmentActivity(activity) } - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - logger.i { "onActivityCreated: ${activity::class.java.simpleName}" } + override fun onActivityPaused(activity: Activity) { + logger.i { "onActivityPaused: ${activity::class.java.simpleName}" } + // Intentionally keep ref: a paused Activity can still host a BiometricPrompt that is about + // to resume the same instance (e.g. a transient overlay). } - override fun onActivityStarted(activity: Activity) { - logger.i { "onActivityStarted: ${activity::class.java.simpleName}" } - } override fun onActivityStopped(activity: Activity) { logger.i { "onActivityStopped: ${activity::class.java.simpleName}" } } + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { logger.i { "onActivitySaveInstanceState: ${activity::class.java.simpleName}" } } + + override fun onActivityDestroyed(activity: Activity) { + logger.i { "onActivityDestroyed: ${activity::class.java.simpleName}" } + if (ref?.get() === activity) ref = null + } + + private fun captureIfFragmentActivity(activity: Activity) { + if (activity is FragmentActivity) ref = WeakReference(activity) + } } 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 485b636b6..13842f048 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,19 +1,20 @@ package com.softartdev.notedelight.presentation.signin -sealed interface SignInResult { +data class SignInResult( + val state: State = State.Form, + val biometricVisible: Boolean = false, +) { + sealed interface State { + data object Form : State - val biometricVisible: Boolean + data object Progress : State - data class Form(override val biometricVisible: Boolean = false) : SignInResult + sealed interface Error : State { + data object EmptyPass : Error - data class Progress(override val biometricVisible: Boolean = false) : SignInResult + data object IncorrectPass : Error - sealed interface Error : SignInResult { - - data class EmptyPass(override val biometricVisible: Boolean = false) : Error - - data class IncorrectPass(override val biometricVisible: Boolean = false) : Error - - data class Biometric(override val biometricVisible: Boolean = false) : Error + data object Biometric : Error + } } } 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 c3a1c3b96..b35fcf779 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 @@ -23,7 +23,7 @@ class SignInViewModel( ) : ViewModel() { private val logger = Logger.withTag(this@SignInViewModel::class.simpleName.toString()) - private val mutableStateFlow: MutableStateFlow = MutableStateFlow(SignInResult.Form()) + private val mutableStateFlow: MutableStateFlow = MutableStateFlow(SignInResult()) val stateFlow: StateFlow = mutableStateFlow var autofillManager: AutofillManager? = null @@ -41,28 +41,36 @@ class SignInViewModel( private fun refreshBiometric() = viewModelScope.launch { val visible: Boolean = biometricInteractor.hasStoredPassword() && biometricInteractor.canAuthenticate() - mutableStateFlow.value = SignInResult.Form(biometricVisible = visible) + mutableStateFlow.update { it.copy(biometricVisible = visible) } } private fun signInWithBiometric(title: String, subtitle: String, negativeButton: String) = viewModelScope.launch { CountingIdlingRes.increment() - setState { SignInResult.Progress(it) } + mutableStateFlow.update { it.copy(state = SignInResult.State.Progress) } try { when (val res: DecryptedPasswordResult = biometricInteractor.decryptStoredPassword(title, subtitle, negativeButton)) { - is DecryptedPasswordResult.Success -> setState { signInInternal(res.password, it) } + is DecryptedPasswordResult.Success -> mutableStateFlow.update { + it.copy(state = signInInternal(res.password)) + } is DecryptedPasswordResult.Failure -> when (res.result) { - BiometricResult.Cancelled -> setState { SignInResult.Form(it) } + BiometricResult.Cancelled -> mutableStateFlow.update { + it.copy(state = SignInResult.State.Form) + } BiometricResult.Unavailable -> { biometricInteractor.clearStoredPassword() - mutableStateFlow.value = SignInResult.Form(biometricVisible = false) + mutableStateFlow.update { + it.copy(state = SignInResult.State.Form, biometricVisible = false) + } + } + else -> mutableStateFlow.update { + it.copy(state = SignInResult.State.Error.Biometric) } - else -> setState { SignInResult.Error.Biometric(it) } } } } catch (error: Throwable) { logger.e(error) { "Error during biometric sign in" } router.navigate(route = AppNavGraph.ErrorDialog(message = error.message)) - setState { SignInResult.Form(it) } + mutableStateFlow.update { it.copy(state = SignInResult.State.Form) } } finally { CountingIdlingRes.decrement() } @@ -70,31 +78,27 @@ class SignInViewModel( private fun signIn(pass: CharSequence) = viewModelScope.launch { CountingIdlingRes.increment() - setState { SignInResult.Progress(it) } + mutableStateFlow.update { it.copy(state = SignInResult.State.Progress) } try { - setState { signInInternal(pass, it) } + val nextState: SignInResult.State = signInInternal(pass) + mutableStateFlow.update { it.copy(state = nextState) } } catch (error: Throwable) { logger.e(error) { "Error during sign in" } autofillManager?.cancel() router.navigate(route = AppNavGraph.ErrorDialog(message = error.message)) - setState { SignInResult.Form(it) } + mutableStateFlow.update { it.copy(state = SignInResult.State.Form) } } finally { CountingIdlingRes.decrement() } } - private suspend fun signInInternal(pass: CharSequence, biometricVisible: Boolean): SignInResult = when { - pass.isEmpty() -> SignInResult.Error.EmptyPass(biometricVisible) + private suspend fun signInInternal(pass: CharSequence): SignInResult.State = when { + pass.isEmpty() -> SignInResult.State.Error.EmptyPass checkPasswordUseCase(pass) -> { autofillManager?.commit() router.navigateClearingBackStack(AppNavGraph.Main) - SignInResult.Form(biometricVisible) + SignInResult.State.Form } - else -> SignInResult.Error.IncorrectPass(biometricVisible) - } - - // Atomically rewrites the SignInResult while preserving the current biometricVisible flag. - private inline fun setState(transform: (biometricVisible: Boolean) -> SignInResult) { - mutableStateFlow.update { transform(it.biometricVisible) } + else -> SignInResult.State.Error.IncorrectPass } } 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 f78b1c1a8..50bb20a81 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 @@ -70,15 +70,15 @@ fun SignInScreen(signInViewModel: SignInViewModel) { signInViewModel.onAction(SignInAction.RefreshBiometric) } SignInScreenBody( - showLoading = signInResultState.value is SignInResult.Progress, + showLoading = signInResultState.value.state is SignInResult.State.Progress, passwordState = passwordState, - labelResource = when (signInResultState.value) { - is SignInResult.Error.EmptyPass -> Res.string.empty_password - is SignInResult.Error.IncorrectPass -> Res.string.incorrect_password - is SignInResult.Error.Biometric -> Res.string.biometric_error + labelResource = when (signInResultState.value.state) { + is SignInResult.State.Error.EmptyPass -> Res.string.empty_password + is SignInResult.State.Error.IncorrectPass -> Res.string.incorrect_password + is SignInResult.State.Error.Biometric -> Res.string.biometric_error else -> Res.string.enter_password }, - isError = signInResultState.value is SignInResult.Error, + isError = signInResultState.value.state is SignInResult.State.Error, biometricVisible = signInResultState.value.biometricVisible, onAction = signInViewModel::onAction, ) From 8ce0fdb57897aa6657efa706a5b59e7de35d16f5 Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Wed, 29 Apr 2026 13:00:12 +0400 Subject: [PATCH 06/13] refactor: extract biometric feature into dedicated feature modules Move BiometricInteractor (expect/actuals), BiometricResult, DecryptedPasswordResult, and CurrentActivityProvider to feature:biometric:domain; move BiometricEnrollViewModel, BiometricEnrollResult, and BiometricEnrollAction to feature:biometric:presentation. Update core:presentation and core:ui build files and module READMEs accordingly. Co-Authored-By: Claude Opus 4.7 --- core/presentation/README.md | 2 + core/presentation/build.gradle.kts | 2 +- core/ui/README.md | 5 ++ core/ui/build.gradle.kts | 2 + feature/biometric/domain/README.md | 69 +++++++++++++++++++ feature/biometric/domain/build.gradle.kts | 49 +++++++++++++ .../interactor/BiometricInteractor.android.kt | 0 .../interactor/CurrentActivityProvider.kt | 0 .../interactor/BiometricInteractor.kt | 0 .../notedelight/interactor/BiometricResult.kt | 0 .../interactor/BiometricInteractor.ios.kt | 0 .../interactor/BiometricInteractor.jvm.kt | 0 .../interactor/BiometricInteractor.wasmJs.kt | 0 feature/biometric/presentation/README.md | 67 ++++++++++++++++++ .../biometric/presentation/build.gradle.kts | 61 ++++++++++++++++ .../biometric/BiometricEnrollResult.kt | 0 .../biometric/BiometricEnrollViewModel.kt | 0 settings.gradle.kts | 2 + 18 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 feature/biometric/domain/README.md create mode 100644 feature/biometric/domain/build.gradle.kts rename {core/presentation => feature/biometric/domain}/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt (100%) rename {core/presentation => feature/biometric/domain}/src/androidMain/kotlin/com/softartdev/notedelight/interactor/CurrentActivityProvider.kt (100%) rename {core/presentation => feature/biometric/domain}/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt (100%) rename {core/presentation => feature/biometric/domain}/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricResult.kt (100%) rename {core/presentation => feature/biometric/domain}/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt (100%) rename {core/presentation => feature/biometric/domain}/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt (100%) rename {core/presentation => feature/biometric/domain}/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt (100%) create mode 100644 feature/biometric/presentation/README.md create mode 100644 feature/biometric/presentation/build.gradle.kts rename {core => feature/biometric}/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollResult.kt (100%) rename {core => feature/biometric}/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt (100%) 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 bd4787c2c..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) @@ -47,7 +48,6 @@ kotlin { } androidMain.dependencies { implementation(libs.androidx.appcompat) - implementation(libs.androidx.biometric) } val androidHostTest by getting { dependencies { 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/feature/biometric/domain/README.md b/feature/biometric/domain/README.md new file mode 100644 index 000000000..f77a78750 --- /dev/null +++ b/feature/biometric/domain/README.md @@ -0,0 +1,69 @@ +# 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. + +## API + +### `BiometricInteractor` + +```kotlin +expect class BiometricInteractor { + suspend fun canAuthenticate(): Boolean + fun hasStoredPassword(): Boolean + suspend fun encryptAndStorePassword(password, title, subtitle, negativeButton): BiometricResult + suspend fun decryptStoredPassword(title, subtitle, negativeButton): DecryptedPasswordResult + fun clearStoredPassword() +} +``` + +### `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 class Failure(val result: BiometricResult) : DecryptedPasswordResult +} +``` + +## Platform Implementations + +### Android (`androidMain`) + +- `BiometricInteractor.android.kt`: Wraps `androidx.biometric.BiometricPrompt` with AES-GCM/AndroidKeyStore encryption. The encrypted `{ciphertext, iv}` pair is stored in `SharedPreferences`; the AES key is hardware-bound and requires biometric authentication to use. +- `CurrentActivityProvider.kt`: Implements `Application.ActivityLifecycleCallbacks` to expose the current `FragmentActivity` to `BiometricPrompt` without coupling to a specific Activity instance. + +Key design decisions: +- `BiometricPrompt.authenticate()` must run on the main thread — `runPrompt()` uses `withContext(Dispatchers.Main.immediate)`. +- `CurrentActivityProvider` sets the ref on `onActivityCreated`, `onActivityStarted`, and `onActivityResumed`; clears only on `onActivityDestroyed` — deliberate: brief pause (overlay, config change) must not blank the host. +- `setInvalidatedByBiometricEnrollment(true)` wrapped in `Build.VERSION.SDK_INT >= Build.VERSION_CODES.N` (API 24 lint). + +### iOS (`iosMain`) + +Uses `LocalAuthentication.LAContext.evaluatePolicy` + Keychain with `kSecAccessControlBiometryCurrentSet` to bind the stored password to the current biometric set. + +### Desktop/Web (`jvmMain`, `wasmJsMain`) + +Stub actuals — `canAuthenticate()` returns `false`, all operations return `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`) diff --git a/feature/biometric/domain/build.gradle.kts b/feature/biometric/domain/build.gradle.kts new file mode 100644 index 000000000..efc1d56d6 --- /dev/null +++ b/feature/biometric/domain/build.gradle.kts @@ -0,0 +1,49 @@ +@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) + } + } + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") +} + +dependencies { + coreLibraryDesugaring(libs.desugar) +} diff --git a/core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt similarity index 100% rename from core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt rename to feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt diff --git a/core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/CurrentActivityProvider.kt b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/CurrentActivityProvider.kt similarity index 100% rename from core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/CurrentActivityProvider.kt rename to feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/CurrentActivityProvider.kt diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt b/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt similarity index 100% rename from core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt rename to feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricResult.kt b/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricResult.kt similarity index 100% rename from core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricResult.kt rename to feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricResult.kt diff --git a/core/presentation/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt b/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt similarity index 100% rename from core/presentation/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt rename to feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt diff --git a/core/presentation/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt b/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt similarity index 100% rename from core/presentation/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt rename to feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt diff --git a/core/presentation/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt b/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt similarity index 100% rename from core/presentation/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt rename to feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt 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/core/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 similarity index 100% rename from core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollResult.kt rename to feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollResult.kt diff --git a/core/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 similarity index 100% rename from core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt rename to feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt 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") From 52972dddda879d16828c9edb9c379673f822d9ca Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Thu, 30 Apr 2026 02:29:36 +0400 Subject: [PATCH 07/13] refactor: migrate biometric storage to DataStore and update API to suspend - Replace `SharedPreferences` with `androidx.datastore.preferences` in `BiometricInteractor.android.kt` for more robust data handling. - Update `hasStoredPassword()` and `clearStoredPassword()` to `suspend` functions across common and platform-specific implementations. - Add `androidx.datastore` dependency to `libs.versions.toml` and include it in the `biometric:domain` module. - Update `SettingsViewModelTest`, `ChangeViewModelTest`, `ConfirmViewModelTest`, and `EnterViewModelTest` to handle the new `suspend` method signatures. - Enhance error logging in `SignInViewModel` to capture specific `BiometricResult.Error` messages. - Update `SignInScreen` Compose preview to include the `biometricVisible` parameter. --- .../settings/SettingsViewModelTest.kt | 2 +- .../security/change/ChangeViewModelTest.kt | 6 ++- .../security/confirm/ConfirmViewModelTest.kt | 6 ++- .../security/enter/EnterViewModelTest.kt | 6 ++- .../presentation/signin/SignInViewModel.kt | 3 +- .../notedelight/ui/signin/SignInScreen.kt | 2 +- feature/biometric/domain/build.gradle.kts | 1 + .../interactor/BiometricInteractor.android.kt | 51 +++++++++++-------- .../interactor/BiometricInteractor.kt | 4 +- .../interactor/BiometricInteractor.ios.kt | 4 +- .../interactor/BiometricInteractor.jvm.kt | 4 +- .../interactor/BiometricInteractor.wasmJs.kt | 4 +- gradle/libs.versions.toml | 2 + 13 files changed, 61 insertions(+), 34 deletions(-) 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 2204bc3f1..1df324f65 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 @@ -72,8 +72,8 @@ class SettingsViewModelTest { // updateSwitches() would NPE and route to ErrorDialog, breaking unrelated tests. runBlocking { Mockito.`when`(mockBiometricInteractor.canAuthenticate()).thenReturn(false) + Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(false) } - Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(false) } @After 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 6f864f0d3..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 @@ -13,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 @@ -51,7 +52,10 @@ class ChangeViewModelTest { ) @Before - fun setUp() = Logger.setLogWriters(PrintLogWriter()) + fun setUp() { + Logger.setLogWriters(PrintLogWriter()) + runBlocking { Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(false) } + } @After fun tearDown() { 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 ce2117669..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 @@ -12,6 +12,7 @@ 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 @@ -48,7 +49,10 @@ class ConfirmViewModelTest { ) @Before - fun setUp() = Logger.setLogWriters(PrintLogWriter()) + fun setUp() { + Logger.setLogWriters(PrintLogWriter()) + runBlocking { Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(false) } + } @After fun tearDown() { 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 4f162c0df..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 @@ -13,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 @@ -51,7 +52,10 @@ class EnterViewModelTest { ) @Before - fun setUp() = Logger.setLogWriters(PrintLogWriter()) + fun setUp() { + Logger.setLogWriters(PrintLogWriter()) + runBlocking { Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(false) } + } @After fun tearDown() { 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 b35fcf779..6d48e4b9d 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 @@ -52,7 +52,7 @@ class SignInViewModel( is DecryptedPasswordResult.Success -> mutableStateFlow.update { it.copy(state = signInInternal(res.password)) } - is DecryptedPasswordResult.Failure -> when (res.result) { + is DecryptedPasswordResult.Failure -> when (val err: BiometricResult = res.result) { BiometricResult.Cancelled -> mutableStateFlow.update { it.copy(state = SignInResult.State.Form) } @@ -63,6 +63,7 @@ class SignInViewModel( } } else -> mutableStateFlow.update { + logger.e { (err as? BiometricResult.Error)?.message ?: err.toString() } it.copy(state = SignInResult.State.Error.Biometric) } } 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 50bb20a81..6770dfa1b 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 @@ -157,4 +157,4 @@ fun SignInScreenBody( @Preview @Composable -fun PreviewSignInScreen() = SignInScreenBody() +fun PreviewSignInScreen() = SignInScreenBody(biometricVisible = true) diff --git a/feature/biometric/domain/build.gradle.kts b/feature/biometric/domain/build.gradle.kts index efc1d56d6..e8edf526d 100644 --- a/feature/biometric/domain/build.gradle.kts +++ b/feature/biometric/domain/build.gradle.kts @@ -39,6 +39,7 @@ kotlin { androidMain.dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.biometric) + implementation(libs.androidx.datastore.preferences) } } compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") diff --git a/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt index 91995b3c2..9330f330b 100644 --- a/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt +++ b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt @@ -2,7 +2,6 @@ package com.softartdev.notedelight.interactor import android.app.Application import android.content.Context -import android.content.SharedPreferences import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyPermanentlyInvalidatedException @@ -12,10 +11,16 @@ import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat -import androidx.core.content.edit +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 androidx.fragment.app.FragmentActivity import co.touchlab.kermit.Logger import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.security.KeyStore @@ -29,15 +34,19 @@ actual class BiometricInteractor(context: Context) { private val logger = Logger.withTag("BiometricInteractor") private val appContext: Context = context.applicationContext private val activityProvider = CurrentActivityProvider(appContext as Application) - private val prefs: SharedPreferences = - appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private val dataStore: DataStore = PreferenceDataStoreFactory.create( + produceFile = { appContext.preferencesDataStoreFile(PREFS_NAME) } + ) actual suspend fun canAuthenticate(): Boolean = BiometricManager .from(appContext) .canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS - actual fun hasStoredPassword(): Boolean = - prefs.contains(KEY_CIPHERTEXT) && prefs.contains(KEY_IV) + actual suspend fun hasStoredPassword(): Boolean { + val prefs: Preferences = dataStore.data.first() + return prefs.contains(KEY_CIPHERTEXT) && prefs.contains(KEY_IV) + } actual suspend fun encryptAndStorePassword( password: CharSequence, @@ -60,9 +69,9 @@ actual class BiometricInteractor(context: Context) { 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)) - prefs.edit { - putString(KEY_CIPHERTEXT, Base64.encodeToString(out, Base64.NO_WRAP)) - putString(KEY_IV, Base64.encodeToString(auth.cipher.iv, Base64.NO_WRAP)) + dataStore.edit { prefs -> + prefs[KEY_CIPHERTEXT] = Base64.encodeToString(out, Base64.NO_WRAP) + prefs[KEY_IV] = Base64.encodeToString(auth.cipher.iv, Base64.NO_WRAP) } BiometricResult.Success } @@ -75,15 +84,17 @@ actual class BiometricInteractor(context: Context) { subtitle: String, negativeButton: String, ): DecryptedPasswordResult { - if (!hasStoredPassword()) { - return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) - } + val prefs: Preferences = dataStore.data.first() + val ciphertextStr: String = prefs[KEY_CIPHERTEXT] + ?: return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) + val ivStr: String = prefs[KEY_IV] + ?: return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) val activity: FragmentActivity = activityProvider.current ?: return DecryptedPasswordResult.Failure( result = BiometricResult.Error("No active Activity for BiometricPrompt") ) - val ciphertext: ByteArray = Base64.decode(prefs.getString(KEY_CIPHERTEXT, null), Base64.NO_WRAP) - val iv: ByteArray = Base64.decode(prefs.getString(KEY_IV, null), Base64.NO_WRAP) + 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() @@ -122,10 +133,10 @@ actual class BiometricInteractor(context: Context) { } } - actual fun clearStoredPassword() { - prefs.edit { - remove(KEY_CIPHERTEXT) - remove(KEY_IV) + actual suspend fun clearStoredPassword() { + dataStore.edit { prefs -> + prefs.remove(KEY_CIPHERTEXT) + prefs.remove(KEY_IV) } runCatching { KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }.deleteEntry(KEY_ALIAS) @@ -217,7 +228,7 @@ actual class BiometricInteractor(context: Context) { private const val TRANSFORMATION = "AES/GCM/NoPadding" private const val GCM_TAG_BITS = 128 private const val PREFS_NAME = "notedelight_biometric_prefs" - private const val KEY_CIPHERTEXT = "ciphertext" - private const val KEY_IV = "iv" + private val KEY_CIPHERTEXT: Preferences.Key = stringPreferencesKey("ciphertext") + private val KEY_IV: Preferences.Key = stringPreferencesKey("iv") } } 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 index ffdb53345..9f7468020 100644 --- 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 @@ -4,7 +4,7 @@ expect class BiometricInteractor { suspend fun canAuthenticate(): Boolean - fun hasStoredPassword(): Boolean + suspend fun hasStoredPassword(): Boolean suspend fun encryptAndStorePassword( password: CharSequence, @@ -19,5 +19,5 @@ expect class BiometricInteractor { negativeButton: String, ): DecryptedPasswordResult - fun clearStoredPassword() + suspend fun clearStoredPassword() } diff --git a/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt b/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt index ea3ff91d1..e4c642453 100644 --- a/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt +++ b/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt @@ -64,7 +64,7 @@ actual class BiometricInteractor { actual suspend fun canAuthenticate(): Boolean = LAContext() .canEvaluatePolicy(LAPolicyDeviceOwnerAuthenticationWithBiometrics, null) - actual fun hasStoredPassword(): Boolean = memScoped { + actual suspend fun hasStoredPassword(): Boolean = memScoped { val service: CFTypeRef? = CFBridgingRetain(SERVICE) val account: CFTypeRef? = CFBridgingRetain(ACCOUNT) val query: CFMutableDictionaryRef? = newMutableDict() @@ -184,7 +184,7 @@ actual class BiometricInteractor { } } - actual fun clearStoredPassword(): Unit = memScoped { + actual suspend fun clearStoredPassword(): Unit = memScoped { val service: CFTypeRef? = CFBridgingRetain(SERVICE) val account: CFTypeRef? = CFBridgingRetain(ACCOUNT) val query: CFMutableDictionaryRef? = newMutableDict() diff --git a/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt b/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt index d347b2e72..77261eded 100644 --- a/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt +++ b/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt @@ -3,7 +3,7 @@ package com.softartdev.notedelight.interactor actual class BiometricInteractor { actual suspend fun canAuthenticate(): Boolean = false - actual fun hasStoredPassword(): Boolean = false + actual suspend fun hasStoredPassword(): Boolean = false actual suspend fun encryptAndStorePassword( password: CharSequence, @@ -18,5 +18,5 @@ actual class BiometricInteractor { negativeButton: String, ): DecryptedPasswordResult = DecryptedPasswordResult.Failure(BiometricResult.Unavailable) - actual fun clearStoredPassword() = Unit + actual suspend fun clearStoredPassword() = Unit } diff --git a/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt b/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt index d347b2e72..77261eded 100644 --- a/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt +++ b/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt @@ -3,7 +3,7 @@ package com.softartdev.notedelight.interactor actual class BiometricInteractor { actual suspend fun canAuthenticate(): Boolean = false - actual fun hasStoredPassword(): Boolean = false + actual suspend fun hasStoredPassword(): Boolean = false actual suspend fun encryptAndStorePassword( password: CharSequence, @@ -18,5 +18,5 @@ actual class BiometricInteractor { negativeButton: String, ): DecryptedPasswordResult = DecryptedPasswordResult.Failure(BiometricResult.Unavailable) - actual fun clearStoredPassword() = Unit + actual suspend fun clearStoredPassword() = Unit } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3bf1a08eb..aac95aa52 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ 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" @@ -122,6 +123,7 @@ compose-adaptive-navigation = { module = "org.jetbrains.compose.material3.adapti 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" } From 5478fbfd640993083e5b0808315a796eeb9803a7 Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Thu, 30 Apr 2026 12:58:42 +0400 Subject: [PATCH 08/13] refactor: improve biometric architecture and activity handling - Replace `CurrentActivityProvider` with a Compose-friendly `BiometricPlatformWrapper` pattern to provide the Android host `FragmentActivity`. - Introduce `rememberBiometricPlatformWrapper()` expect/actual function to capture the platform host context from the UI layer. - Refactor `BiometricInteractor` to accept `BiometricPlatformWrapper` in encryption and decryption methods. - Introduce `BiometricCredentialsStore` on Android to encapsulate DataStore Preferences logic for storing encrypted credentials. - Refine `DecryptedPasswordResult` domain model to explicitly distinguish between `Cancelled`, `Unavailable`, and `Failure` states. - Update `SignInViewModel` and `BiometricEnrollViewModel` to handle the new platform wrapper and result states. - Refactor `SignInViewModel` to utilize `SnackbarInteractor` for displaying biometric authentication errors. - Update project documentation to reflect the new two-layer protection architecture (Android Keystore + DataStore) and the activity provider pattern. - Update unit tests for `SignInViewModel` to reflect dependency and action changes. --- .../signin/SignInViewModelTest.kt | 31 +++++-- .../presentation/signin/SignInAction.kt | 3 + .../presentation/signin/SignInResult.kt | 2 - .../presentation/signin/SignInViewModel.kt | 49 ++++++---- .../ui/BiometricPlatformHelper.android.kt | 13 +++ .../notedelight/ui/BiometricPlatformHelper.kt | 17 ++++ .../dialog/security/BiometricEnrollDialog.kt | 15 +++- .../notedelight/ui/signin/SignInScreen.kt | 14 ++- .../ui/BiometricPlatformHelper.ios.kt | 7 ++ .../ui/BiometricPlatformHelper.jvm.kt | 7 ++ .../ui/BiometricPlatformHelper.wasmJs.kt | 7 ++ feature/biometric/domain/README.md | 89 ++++++++++++++++--- .../interactor/BiometricCredentialsStore.kt | 63 +++++++++++++ .../interactor/BiometricInteractor.android.kt | 76 +++++----------- .../BiometricPlatformWrapper.android.kt | 5 ++ .../interactor/CurrentActivityProvider.kt | 78 ---------------- .../interactor/BiometricInteractor.kt | 2 + .../interactor/BiometricPlatformWrapper.kt | 9 ++ .../notedelight/interactor/BiometricResult.kt | 4 +- .../interactor/BiometricInteractor.ios.kt | 15 +++- .../BiometricPlatformWrapper.ios.kt | 3 + .../interactor/BiometricInteractor.jvm.kt | 4 +- .../BiometricPlatformWrapper.jvm.kt | 3 + .../interactor/BiometricInteractor.wasmJs.kt | 4 +- .../BiometricPlatformWrapper.wasmJs.kt | 3 + .../biometric/BiometricEnrollResult.kt | 2 + .../biometric/BiometricEnrollViewModel.kt | 12 ++- 27 files changed, 356 insertions(+), 181 deletions(-) create mode 100644 core/ui/src/androidMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.android.kt create mode 100644 core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.kt create mode 100644 core/ui/src/iosMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.ios.kt create mode 100644 core/ui/src/jvmMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.jvm.kt create mode 100644 core/ui/src/wasmJsMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.wasmJs.kt create mode 100644 feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricCredentialsStore.kt create mode 100644 feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.android.kt delete mode 100644 feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/CurrentActivityProvider.kt create mode 100644 feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.kt create mode 100644 feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.ios.kt create mode 100644 feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.jvm.kt create mode 100644 feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricPlatformWrapper.wasmJs.kt 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 21f791a48..70e12706e 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,9 +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.BiometricResult 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 @@ -35,13 +38,14 @@ class SignInViewModelTest { 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 lateinit var signInViewModel: SignInViewModel @Before fun setUp() { signInViewModel = SignInViewModel( - mockCheckPasswordUseCase, mockBiometricInteractor, mockRouter + mockCheckPasswordUseCase, mockBiometricInteractor, mockRouter, mockSnackbarInteractor ) signInViewModel.autofillManager = mockAutofillManager } @@ -138,12 +142,12 @@ class SignInViewModelTest { @Test fun biometricSignInSuccess() = runTest { val pass = StubEditable("pass") - Mockito.`when`(mockBiometricInteractor.decryptStoredPassword(anyObject(), anyObject(), anyObject())) + 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")) + signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c", BiometricPlatformWrapper(Mockito.mock(FragmentActivity::class.java)))) Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Main) cancelAndIgnoreRemainingEvents() } @@ -151,13 +155,26 @@ class SignInViewModelTest { @Test fun biometricSignInUnavailableClearsState() = runTest { - Mockito.`when`(mockBiometricInteractor.decryptStoredPassword(anyObject(), anyObject(), anyObject())) - .thenReturn(DecryptedPasswordResult.Failure(BiometricResult.Unavailable)) + Mockito.`when`(mockBiometricInteractor.decryptStoredPassword(anyObject(), anyObject(), anyObject(), anyObject())) + .thenReturn(DecryptedPasswordResult.Unavailable) signInViewModel.stateFlow.test { assertFalse(awaitItem().biometricVisible) - signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c")) + signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c", BiometricPlatformWrapper(Mockito.mock(FragmentActivity::class.java)))) 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.mock(FragmentActivity::class.java)))) + Mockito.verify(mockSnackbarInteractor).showMessage(SnackbarMessage.Simple(errorMessage)) + cancelAndIgnoreRemainingEvents() + } + } } 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 2aab06a5c..41c6e3c0d 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,5 +1,7 @@ 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 @@ -8,5 +10,6 @@ sealed interface SignInAction { val title: String, val subtitle: String, val negativeButton: String, + val biometricPlatformWrapper: BiometricPlatformWrapper? = null, ) : 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 13842f048..4e1eda7ad 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 @@ -13,8 +13,6 @@ data class SignInResult( data object EmptyPass : Error data object IncorrectPass : Error - - data object Biometric : Error } } } 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 6d48e4b9d..02be57aaa 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 @@ -5,8 +5,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import com.softartdev.notedelight.interactor.BiometricInteractor -import com.softartdev.notedelight.interactor.BiometricResult +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 @@ -20,6 +22,7 @@ class SignInViewModel( private val checkPasswordUseCase: CheckPasswordUseCase, private val biometricInteractor: BiometricInteractor, private val router: Router, + private val snackbarInteractor: SnackbarInteractor, ) : ViewModel() { private val logger = Logger.withTag(this@SignInViewModel::class.simpleName.toString()) @@ -36,6 +39,7 @@ class SignInViewModel( title = action.title, subtitle = action.subtitle, negativeButton = action.negativeButton, + biometricPlatformWrapper = action.biometricPlatformWrapper, ) } @@ -44,29 +48,42 @@ class SignInViewModel( mutableStateFlow.update { it.copy(biometricVisible = visible) } } - private fun signInWithBiometric(title: String, subtitle: String, negativeButton: String) = viewModelScope.launch { + private fun signInWithBiometric( + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper?, + ) = viewModelScope.launch { + val wrapper: BiometricPlatformWrapper = biometricPlatformWrapper ?: run { + logger.e { "BiometricPlatformWrapper is null — cannot show BiometricPrompt" } + return@launch + } CountingIdlingRes.increment() mutableStateFlow.update { it.copy(state = SignInResult.State.Progress) } try { - when (val res: DecryptedPasswordResult = biometricInteractor.decryptStoredPassword(title, subtitle, negativeButton)) { + when (val res: DecryptedPasswordResult = biometricInteractor.decryptStoredPassword( + title = title, + subtitle = subtitle, + negativeButton = negativeButton, + biometricPlatformWrapper = wrapper, + )) { is DecryptedPasswordResult.Success -> mutableStateFlow.update { it.copy(state = signInInternal(res.password)) } - is DecryptedPasswordResult.Failure -> when (val err: BiometricResult = res.result) { - BiometricResult.Cancelled -> mutableStateFlow.update { - it.copy(state = SignInResult.State.Form) - } - BiometricResult.Unavailable -> { - biometricInteractor.clearStoredPassword() - mutableStateFlow.update { - it.copy(state = SignInResult.State.Form, biometricVisible = false) - } - } - else -> mutableStateFlow.update { - logger.e { (err as? BiometricResult.Error)?.message ?: err.toString() } - it.copy(state = SignInResult.State.Error.Biometric) + is DecryptedPasswordResult.Cancelled -> mutableStateFlow.update { + it.copy(state = SignInResult.State.Form) + } + is DecryptedPasswordResult.Unavailable -> { + biometricInteractor.clearStoredPassword() + mutableStateFlow.update { + it.copy(state = SignInResult.State.Form, biometricVisible = false) } } + is DecryptedPasswordResult.Failure -> { + logger.e { res.message } + snackbarInteractor.showMessage(SnackbarMessage.Simple(res.message)) + mutableStateFlow.update { it.copy(state = SignInResult.State.Form) } + } } } catch (error: Throwable) { logger.e(error) { "Error during biometric sign in" } 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..4431238be --- /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.fragment.app.FragmentActivity +import com.softartdev.notedelight.interactor.BiometricPlatformWrapper + +@Composable +actual fun rememberBiometricPlatformWrapper(): BiometricPlatformWrapper { + val activity = LocalActivity.current as? FragmentActivity + ?: error("rememberBiometricPlatformWrapper must be called within a FragmentActivity-hosted composition") + return BiometricPlatformWrapper(activity) +} 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/BiometricEnrollDialog.kt b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricEnrollDialog.kt index fb1dd9f1b..85289803d 100644 --- 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 @@ -21,6 +21,7 @@ import com.softartdev.notedelight.presentation.settings.security.biometric.Biome 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 @@ -41,7 +42,19 @@ import org.jetbrains.compose.resources.stringResource @Composable fun BiometricEnrollDialog(biometricEnrollViewModel: BiometricEnrollViewModel) { val result: BiometricEnrollResult by biometricEnrollViewModel.stateFlow.collectAsState() - ShowBiometricEnrollDialog(result, biometricEnrollViewModel::onAction) + val biometricPlatformWrapper = rememberBiometricPlatformWrapper() + ShowBiometricEnrollDialog( + result = result, + onAction = { action -> + biometricEnrollViewModel.onAction( + if (action is BiometricEnrollAction.OnEnrollClick) { + action.copy(biometricPlatformWrapper = biometricPlatformWrapper) + } else { + action + } + ) + }, + ) } @Composable 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 6770dfa1b..8ac3a2e6c 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 @@ -37,6 +37,7 @@ 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 @@ -45,7 +46,6 @@ 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_error 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 @@ -63,6 +63,7 @@ fun SignInScreen(signInViewModel: SignInViewModel) { val signInResultState: State = signInViewModel.stateFlow.collectAsState() val passwordState: MutableState = remember { mutableStateOf("") } val autofillManager: AutofillManager? = LocalAutofillManager.current + val biometricPlatformWrapper = rememberBiometricPlatformWrapper() LaunchedEffect(key1 = signInViewModel, key2 = autofillManager) { signInViewModel.autofillManager = autofillManager } @@ -75,12 +76,19 @@ fun SignInScreen(signInViewModel: SignInViewModel) { labelResource = when (signInResultState.value.state) { is SignInResult.State.Error.EmptyPass -> Res.string.empty_password is SignInResult.State.Error.IncorrectPass -> Res.string.incorrect_password - is SignInResult.State.Error.Biometric -> Res.string.biometric_error else -> Res.string.enter_password }, isError = signInResultState.value.state is SignInResult.State.Error, biometricVisible = signInResultState.value.biometricVisible, - onAction = signInViewModel::onAction, + onAction = { action -> + signInViewModel.onAction( + if (action is SignInAction.OnBiometricClick) { + action.copy(biometricPlatformWrapper = biometricPlatformWrapper) + } else { + action + } + ) + }, ) } 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..2748b30c3 --- /dev/null +++ b/core/ui/src/iosMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.ios.kt @@ -0,0 +1,7 @@ +package com.softartdev.notedelight.ui + +import androidx.compose.runtime.Composable +import com.softartdev.notedelight.interactor.BiometricPlatformWrapper + +@Composable +actual fun rememberBiometricPlatformWrapper(): BiometricPlatformWrapper = BiometricPlatformWrapper() 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..2748b30c3 --- /dev/null +++ b/core/ui/src/jvmMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.jvm.kt @@ -0,0 +1,7 @@ +package com.softartdev.notedelight.ui + +import androidx.compose.runtime.Composable +import com.softartdev.notedelight.interactor.BiometricPlatformWrapper + +@Composable +actual fun rememberBiometricPlatformWrapper(): BiometricPlatformWrapper = BiometricPlatformWrapper() 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..2748b30c3 --- /dev/null +++ b/core/ui/src/wasmJsMain/kotlin/com/softartdev/notedelight/ui/BiometricPlatformHelper.wasmJs.kt @@ -0,0 +1,7 @@ +package com.softartdev.notedelight.ui + +import androidx.compose.runtime.Composable +import com.softartdev.notedelight.interactor.BiometricPlatformWrapper + +@Composable +actual fun rememberBiometricPlatformWrapper(): BiometricPlatformWrapper = BiometricPlatformWrapper() diff --git a/feature/biometric/domain/README.md b/feature/biometric/domain/README.md index f77a78750..6f314f985 100644 --- a/feature/biometric/domain/README.md +++ b/feature/biometric/domain/README.md @@ -4,7 +4,7 @@ Biometric authentication domain module — platform-agnostic contract plus platf ## Overview -Provides the `BiometricInteractor` expect class and its platform actuals, as well as `BiometricResult` / `DecryptedPasswordResult` domain types. +Provides the `BiometricInteractor` expect class and its platform actuals, as well as `BiometricResult` / `DecryptedPasswordResult` domain types, and the `ActivityProvider` expect class used to pass the Android host Activity to the biometric prompt from Compose. ## API @@ -13,13 +13,26 @@ Provides the `BiometricInteractor` expect class and its platform actuals, as wel ```kotlin expect class BiometricInteractor { suspend fun canAuthenticate(): Boolean - fun hasStoredPassword(): Boolean - suspend fun encryptAndStorePassword(password, title, subtitle, negativeButton): BiometricResult - suspend fun decryptStoredPassword(title, subtitle, negativeButton): DecryptedPasswordResult - fun clearStoredPassword() + suspend fun hasStoredPassword(): Boolean + suspend fun encryptAndStorePassword( + password: CharSequence, + title: String, + subtitle: String, + negativeButton: String, + activityProvider: ActivityProvider, + ): BiometricResult + suspend fun decryptStoredPassword( + title: String, + subtitle: String, + negativeButton: String, + activityProvider: ActivityProvider, + ): DecryptedPasswordResult + suspend fun clearStoredPassword() } ``` +The `activityProvider` parameter is created in `@Composable` functions via `rememberActivityProvider()` (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 @@ -37,29 +50,78 @@ sealed interface BiometricResult { ```kotlin sealed interface DecryptedPasswordResult { data class Success(val password: CharSequence) : DecryptedPasswordResult - data class Failure(val result: BiometricResult) : 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`. + +### `ActivityProvider` + +```kotlin +expect class ActivityProvider +// Android actual: actual class ActivityProvider(val activity: FragmentActivity) +// iOS/JVM/wasmJs: actual class ActivityProvider (empty stub) +``` + +Created from a Composable using `rememberActivityProvider()` (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`) -- `BiometricInteractor.android.kt`: Wraps `androidx.biometric.BiometricPrompt` with AES-GCM/AndroidKeyStore encryption. The encrypted `{ciphertext, iv}` pair is stored in `SharedPreferences`; the AES key is hardware-bound and requires biometric authentication to use. -- `CurrentActivityProvider.kt`: Implements `Application.ActivityLifecycleCallbacks` to expose the current `FragmentActivity` to `BiometricPrompt` without coupling to a specific Activity instance. +**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. -Key design decisions: +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)`. -- `CurrentActivityProvider` sets the ref on `onActivityCreated`, `onActivityStarted`, and `onActivityResumed`; clears only on `onActivityDestroyed` — deliberate: brief pause (overlay, config change) must not blank the host. -- `setInvalidatedByBiometricEnrollment(true)` wrapped in `Build.VERSION.SDK_INT >= Build.VERSION_CODES.N` (API 24 lint). +- `ActivityProvider` is supplied from the composable layer (`rememberActivityProvider()` 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`) -Uses `LocalAuthentication.LAContext.evaluatePolicy` + Keychain with `kSecAccessControlBiometryCurrentSet` to bind the stored password to the current biometric set. +**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`. Biometric is not supported on Desktop or Web. +Stub actuals — `canAuthenticate()` returns `false`, all operations return `Unavailable` or `BiometricResult.Unavailable`. Biometric is not supported on Desktop or Web. ## Dependencies @@ -67,3 +129,4 @@ Stub actuals — `canAuthenticate()` returns `false`, all operations return `Una - `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/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/BiometricInteractor.android.kt b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt index 9330f330b..6885719ac 100644 --- a/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt +++ b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt @@ -1,6 +1,5 @@ package com.softartdev.notedelight.interactor -import android.app.Application import android.content.Context import android.os.Build import android.security.keystore.KeyGenParameterSpec @@ -11,16 +10,9 @@ import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat -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 androidx.fragment.app.FragmentActivity import co.touchlab.kermit.Logger import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.security.KeyStore @@ -33,29 +25,22 @@ import kotlin.coroutines.resume actual class BiometricInteractor(context: Context) { private val logger = Logger.withTag("BiometricInteractor") private val appContext: Context = context.applicationContext - private val activityProvider = CurrentActivityProvider(appContext as Application) - - private val dataStore: DataStore = PreferenceDataStoreFactory.create( - produceFile = { appContext.preferencesDataStoreFile(PREFS_NAME) } - ) + private val credentialsStore = BiometricCredentialsStore(appContext) actual suspend fun canAuthenticate(): Boolean = BiometricManager .from(appContext) .canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS - actual suspend fun hasStoredPassword(): Boolean { - val prefs: Preferences = dataStore.data.first() - return prefs.contains(KEY_CIPHERTEXT) && prefs.contains(KEY_IV) - } + actual suspend fun hasStoredPassword(): Boolean = credentialsStore.hasCredentials() actual suspend fun encryptAndStorePassword( password: CharSequence, title: String, subtitle: String, negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, ): BiometricResult { - val activity: FragmentActivity = activityProvider.current - ?: return BiometricResult.Error("No active Activity for BiometricPrompt") + val activity: FragmentActivity = biometricPlatformWrapper.activity clearStoredPassword() val secretKey: SecretKey = try { createOrGetKey() @@ -69,10 +54,10 @@ actual class BiometricInteractor(context: Context) { 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)) - dataStore.edit { prefs -> - prefs[KEY_CIPHERTEXT] = Base64.encodeToString(out, Base64.NO_WRAP) - prefs[KEY_IV] = Base64.encodeToString(auth.cipher.iv, Base64.NO_WRAP) - } + 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 @@ -83,32 +68,25 @@ actual class BiometricInteractor(context: Context) { title: String, subtitle: String, negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, ): DecryptedPasswordResult { - val prefs: Preferences = dataStore.data.first() - val ciphertextStr: String = prefs[KEY_CIPHERTEXT] - ?: return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) - val ivStr: String = prefs[KEY_IV] - ?: return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) - val activity: FragmentActivity = activityProvider.current - ?: return DecryptedPasswordResult.Failure( - result = BiometricResult.Error("No active Activity for BiometricPrompt") - ) + 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.Failure(BiometricResult.Unavailable) + return DecryptedPasswordResult.Unavailable } } catch (t: KeyPermanentlyInvalidatedException) { logger.e(t) { "Key permanently invalidated" } clearStoredPassword() - return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) + return DecryptedPasswordResult.Unavailable } catch (t: Throwable) { logger.e(t) { "Keystore failure" } - return DecryptedPasswordResult.Failure( - result = BiometricResult.Error(t.message ?: "Keystore failure") - ) + return DecryptedPasswordResult.Failure(t.message ?: "Keystore failure") } val cipher: Cipher = try { Cipher.getInstance(TRANSFORMATION).apply { @@ -117,35 +95,32 @@ actual class BiometricInteractor(context: Context) { } catch (t: KeyPermanentlyInvalidatedException) { logger.e(t) { "Key permanently invalidated" } clearStoredPassword() - return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) + return DecryptedPasswordResult.Unavailable } catch (t: Throwable) { logger.e(t) { "Cipher init failed" } - return DecryptedPasswordResult.Failure( - result = BiometricResult.Error(t.message ?: "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 -> DecryptedPasswordResult.Failure(auth.result) + 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()) + } } } actual suspend fun clearStoredPassword() { - dataStore.edit { prefs -> - prefs.remove(KEY_CIPHERTEXT) - prefs.remove(KEY_IV) - } + credentialsStore.clear() runCatching { KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }.deleteEntry(KEY_ALIAS) } } - /** Test/lifecycle hook: stops listening for Activity events. Not called automatically. */ - fun dispose() = activityProvider.dispose() - private fun existingKey(): SecretKey? { val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } return keyStore.getKey(KEY_ALIAS, null) as? SecretKey @@ -227,8 +202,5 @@ actual class BiometricInteractor(context: Context) { private const val KEY_ALIAS = "notedelight_biometric_key" private const val TRANSFORMATION = "AES/GCM/NoPadding" private const val GCM_TAG_BITS = 128 - 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/androidMain/kotlin/com/softartdev/notedelight/interactor/CurrentActivityProvider.kt b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/CurrentActivityProvider.kt deleted file mode 100644 index 90826e5e4..000000000 --- a/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/CurrentActivityProvider.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.softartdev.notedelight.interactor - -import android.app.Activity -import android.app.Application -import android.os.Bundle -import androidx.fragment.app.FragmentActivity -import co.touchlab.kermit.Logger -import java.lang.ref.WeakReference - -/** - * Tracks the latest live `FragmentActivity` for app-scoped components that need an Activity host - * (e.g. `BiometricPrompt`). The reference is set during creation/start/resume — the three early - * lifecycle callbacks that are guaranteed to fire while the Activity is still live — and cleared on - * destruction. We deliberately do **not** clear in `onActivityPaused`/`onActivityStopped` so that a - * brief pause (overlay dialog, configuration change in flight) does not blank the host. - * - * The Activity is held weakly so a destroyed instance pending GC cannot keep its window leaked - * through this provider. Registered as an [Application.ActivityLifecycleCallbacks] for the whole - * process at construction; call [dispose] to unregister (mainly useful for tests — singletons - * normally live for the lifetime of the process). - */ -internal class CurrentActivityProvider( - private val application: Application, -) : Application.ActivityLifecycleCallbacks { - private val logger = Logger.withTag("CurrentActivityProvider") - private var ref: WeakReference? = null - - val current: FragmentActivity? - get() = ref?.get() - - init { - application.registerActivityLifecycleCallbacks(this) - } - - fun dispose() { - application.unregisterActivityLifecycleCallbacks(this) - ref?.clear() - ref = null - } - - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - logger.i { "onActivityCreated: ${activity::class.java.simpleName}" } - captureIfFragmentActivity(activity) - } - - override fun onActivityStarted(activity: Activity) { - logger.i { "onActivityStarted: ${activity::class.java.simpleName}" } - captureIfFragmentActivity(activity) - } - - override fun onActivityResumed(activity: Activity) { - logger.i { "onActivityResumed: ${activity::class.java.simpleName}" } - captureIfFragmentActivity(activity) - } - - override fun onActivityPaused(activity: Activity) { - logger.i { "onActivityPaused: ${activity::class.java.simpleName}" } - // Intentionally keep ref: a paused Activity can still host a BiometricPrompt that is about - // to resume the same instance (e.g. a transient overlay). - } - - override fun onActivityStopped(activity: Activity) { - logger.i { "onActivityStopped: ${activity::class.java.simpleName}" } - } - - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { - logger.i { "onActivitySaveInstanceState: ${activity::class.java.simpleName}" } - } - - override fun onActivityDestroyed(activity: Activity) { - logger.i { "onActivityDestroyed: ${activity::class.java.simpleName}" } - if (ref?.get() === activity) ref = null - } - - private fun captureIfFragmentActivity(activity: Activity) { - if (activity is FragmentActivity) ref = WeakReference(activity) - } -} 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 index 9f7468020..a4b9ec15f 100644 --- 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 @@ -11,12 +11,14 @@ expect class BiometricInteractor { title: String, subtitle: String, negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, ): BiometricResult suspend fun decryptStoredPassword( title: String, subtitle: String, negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, ): DecryptedPasswordResult suspend fun clearStoredPassword() 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 index 3cba47fc7..c31dfecc4 100644 --- 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 @@ -10,5 +10,7 @@ sealed interface BiometricResult { sealed interface DecryptedPasswordResult { data class Success(val password: CharSequence) : DecryptedPasswordResult - data class Failure(val result: BiometricResult) : 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/BiometricInteractor.ios.kt b/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt index e4c642453..e5a599345 100644 --- a/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt +++ b/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt @@ -87,6 +87,7 @@ actual class BiometricInteractor { title: String, subtitle: String, negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, ): BiometricResult { clearStoredPassword() val context = LAContext().apply { @@ -135,9 +136,10 @@ actual class BiometricInteractor { title: String, subtitle: String, negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, ): DecryptedPasswordResult { if (!hasStoredPassword()) { - return DecryptedPasswordResult.Failure(BiometricResult.Unavailable) + return DecryptedPasswordResult.Unavailable } val context = LAContext().apply { localizedReason = "$title\n$subtitle" @@ -172,14 +174,19 @@ actual class BiometricInteractor { if (pwd != null) { DecryptedPasswordResult.Success(pwd) } else { - DecryptedPasswordResult.Failure(BiometricResult.Error("Decoding failed")) + DecryptedPasswordResult.Failure("Decoding failed") } } errSecItemNotFound -> { clearStoredPassword() - DecryptedPasswordResult.Failure(BiometricResult.Unavailable) + 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()) } - else -> DecryptedPasswordResult.Failure(mapKeychainStatus(status)) } } } 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/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt b/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt index 77261eded..4fc407653 100644 --- a/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt +++ b/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt @@ -10,13 +10,15 @@ actual class BiometricInteractor { title: String, subtitle: String, negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, ): BiometricResult = BiometricResult.Unavailable actual suspend fun decryptStoredPassword( title: String, subtitle: String, negativeButton: String, - ): DecryptedPasswordResult = DecryptedPasswordResult.Failure(BiometricResult.Unavailable) + biometricPlatformWrapper: BiometricPlatformWrapper, + ): DecryptedPasswordResult = DecryptedPasswordResult.Unavailable actual suspend fun clearStoredPassword() = Unit } 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/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt b/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt index 77261eded..4fc407653 100644 --- a/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt +++ b/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt @@ -10,13 +10,15 @@ actual class BiometricInteractor { title: String, subtitle: String, negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, ): BiometricResult = BiometricResult.Unavailable actual suspend fun decryptStoredPassword( title: String, subtitle: String, negativeButton: String, - ): DecryptedPasswordResult = DecryptedPasswordResult.Failure(BiometricResult.Unavailable) + biometricPlatformWrapper: BiometricPlatformWrapper, + ): DecryptedPasswordResult = DecryptedPasswordResult.Unavailable actual 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/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 index 45c835c34..9272f0048 100644 --- 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 @@ -1,5 +1,6 @@ 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( @@ -23,5 +24,6 @@ sealed interface BiometricEnrollAction { val title: String, val subtitle: String, val negativeButton: String, + val biometricPlatformWrapper: BiometricPlatformWrapper? = null, ) : 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 index 43ba30b3a..e8620eff1 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -38,7 +39,8 @@ class BiometricEnrollViewModel( is BiometricEnrollAction.OnEnrollClick -> enroll( title = action.title, subtitle = action.subtitle, - negativeButton = action.negativeButton + negativeButton = action.negativeButton, + biometricPlatformWrapper = action.biometricPlatformWrapper, ) } @@ -58,7 +60,12 @@ class BiometricEnrollViewModel( title: String, subtitle: String, negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper?, ) = viewModelScope.launch(context = coroutineDispatchers.io) { + val wrapper: BiometricPlatformWrapper = biometricPlatformWrapper ?: run { + logger.e { "BiometricPlatformWrapper is null — cannot show BiometricPrompt" } + return@launch + } CountingIdlingRes.increment() mutableStateFlow.update(BiometricEnrollResult::showLoading) try { @@ -73,7 +80,8 @@ class BiometricEnrollViewModel( password = password, title = title, subtitle = subtitle, - negativeButton = negativeButton + negativeButton = negativeButton, + biometricPlatformWrapper = wrapper, ) when (result) { is BiometricResult.Success -> withContext(coroutineDispatchers.main) { From 084966b892c13fba672ea6ee5a8d45b79e0553fa Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Thu, 30 Apr 2026 13:10:39 +0400 Subject: [PATCH 09/13] refactor: make BiometricPlatformWrapper non-nullable and simplify action handling - Update `SignInAction.OnBiometricClick` and `BiometricEnrollAction.OnEnrollClick` to require a non-nullable `BiometricPlatformWrapper`. - Remove redundant null checks and logging for `BiometricPlatformWrapper` in `SignInViewModel` and `BiometricEnrollViewModel`. - Refactor `SignInScreen` and `BiometricEnrollDialog` to instantiate the biometric wrapper locally and pass it directly through the UI action flow. - Simplify the Android implementation of `rememberBiometricPlatformWrapper` using a direct cast to `FragmentActivity`. - Clean up `onAction` lambda references in the UI layer by using function references where possible. --- .../presentation/signin/SignInAction.kt | 2 +- .../presentation/signin/SignInViewModel.kt | 8 ++----- .../ui/BiometricPlatformHelper.android.kt | 8 +++---- .../dialog/security/BiometricEnrollDialog.kt | 22 +++++-------------- .../notedelight/ui/signin/SignInScreen.kt | 15 ++++--------- .../biometric/BiometricEnrollResult.kt | 2 +- .../biometric/BiometricEnrollViewModel.kt | 8 ++----- 7 files changed, 19 insertions(+), 46 deletions(-) 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 41c6e3c0d..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 @@ -10,6 +10,6 @@ sealed interface SignInAction { val title: String, val subtitle: String, val negativeButton: String, - val biometricPlatformWrapper: BiometricPlatformWrapper? = null, + val biometricPlatformWrapper: BiometricPlatformWrapper ) : SignInAction } 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 02be57aaa..7bbc23afe 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 @@ -52,12 +52,8 @@ class SignInViewModel( title: String, subtitle: String, negativeButton: String, - biometricPlatformWrapper: BiometricPlatformWrapper?, + biometricPlatformWrapper: BiometricPlatformWrapper, ) = viewModelScope.launch { - val wrapper: BiometricPlatformWrapper = biometricPlatformWrapper ?: run { - logger.e { "BiometricPlatformWrapper is null — cannot show BiometricPrompt" } - return@launch - } CountingIdlingRes.increment() mutableStateFlow.update { it.copy(state = SignInResult.State.Progress) } try { @@ -65,7 +61,7 @@ class SignInViewModel( title = title, subtitle = subtitle, negativeButton = negativeButton, - biometricPlatformWrapper = wrapper, + biometricPlatformWrapper = biometricPlatformWrapper, )) { is DecryptedPasswordResult.Success -> mutableStateFlow.update { it.copy(state = signInInternal(res.password)) 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 index 4431238be..f19204d28 100644 --- 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 @@ -6,8 +6,6 @@ import androidx.fragment.app.FragmentActivity import com.softartdev.notedelight.interactor.BiometricPlatformWrapper @Composable -actual fun rememberBiometricPlatformWrapper(): BiometricPlatformWrapper { - val activity = LocalActivity.current as? FragmentActivity - ?: error("rememberBiometricPlatformWrapper must be called within a FragmentActivity-hosted composition") - return BiometricPlatformWrapper(activity) -} +actual fun rememberBiometricPlatformWrapper(): BiometricPlatformWrapper = BiometricPlatformWrapper( + activity = LocalActivity.current as FragmentActivity +) 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 index 85289803d..c14512cd4 100644 --- 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 @@ -17,6 +17,7 @@ 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 @@ -42,19 +43,7 @@ import org.jetbrains.compose.resources.stringResource @Composable fun BiometricEnrollDialog(biometricEnrollViewModel: BiometricEnrollViewModel) { val result: BiometricEnrollResult by biometricEnrollViewModel.stateFlow.collectAsState() - val biometricPlatformWrapper = rememberBiometricPlatformWrapper() - ShowBiometricEnrollDialog( - result = result, - onAction = { action -> - biometricEnrollViewModel.onAction( - if (action is BiometricEnrollAction.OnEnrollClick) { - action.copy(biometricPlatformWrapper = biometricPlatformWrapper) - } else { - action - } - ) - }, - ) + ShowBiometricEnrollDialog(result, biometricEnrollViewModel::onAction) } @Composable @@ -63,7 +52,8 @@ fun ShowBiometricEnrollDialog( 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) + 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)) }, @@ -81,7 +71,7 @@ fun ShowBiometricEnrollDialog( contentDescription = stringResource(Res.string.enter_password), imeAction = ImeAction.Done, keyboardActions = KeyboardActions { - onAction(BiometricEnrollAction.OnEnrollClick(title, subtitle, negative)) + onAction(BiometricEnrollAction.OnEnrollClick(title, subtitle, negative, bio)) }, labelTag = BIOMETRIC_ENROLL_DIALOG_LABEL_TAG, visibilityTag = BIOMETRIC_ENROLL_DIALOG_VISIBILITY_TAG, @@ -92,7 +82,7 @@ fun ShowBiometricEnrollDialog( confirmButton = { PasswordSaveButton( tag = BIOMETRIC_ENROLL_DIALOG_SAVE_BUTTON_TAG, - onClick = { onAction(BiometricEnrollAction.OnEnrollClick(title, subtitle, negative)) }, + onClick = { onAction(BiometricEnrollAction.OnEnrollClick(title, subtitle, negative, bio)) }, ) }, dismissButton = { 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 8ac3a2e6c..1c062f878 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 @@ -32,6 +32,7 @@ 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 @@ -63,7 +64,6 @@ fun SignInScreen(signInViewModel: SignInViewModel) { val signInResultState: State = signInViewModel.stateFlow.collectAsState() val passwordState: MutableState = remember { mutableStateOf("") } val autofillManager: AutofillManager? = LocalAutofillManager.current - val biometricPlatformWrapper = rememberBiometricPlatformWrapper() LaunchedEffect(key1 = signInViewModel, key2 = autofillManager) { signInViewModel.autofillManager = autofillManager } @@ -80,15 +80,7 @@ fun SignInScreen(signInViewModel: SignInViewModel) { }, isError = signInResultState.value.state is SignInResult.State.Error, biometricVisible = signInResultState.value.biometricVisible, - onAction = { action -> - signInViewModel.onAction( - if (action is SignInAction.OnBiometricClick) { - action.copy(biometricPlatformWrapper = biometricPlatformWrapper) - } else { - action - } - ) - }, + onAction = signInViewModel::onAction, ) } @@ -144,12 +136,13 @@ fun SignInScreenBody( 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)) }, + onClick = { onAction(SignInAction.OnBiometricClick(title, subtitle, negative, bio)) }, ) { Icon( imageVector = Icons.Default.Fingerprint, 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 index 9272f0048..50c20ada3 100644 --- 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 @@ -24,6 +24,6 @@ sealed interface BiometricEnrollAction { val title: String, val subtitle: String, val negativeButton: String, - val biometricPlatformWrapper: BiometricPlatformWrapper? = null, + 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 index e8620eff1..4b53b9f2d 100644 --- 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 @@ -60,12 +60,8 @@ class BiometricEnrollViewModel( title: String, subtitle: String, negativeButton: String, - biometricPlatformWrapper: BiometricPlatformWrapper?, + biometricPlatformWrapper: BiometricPlatformWrapper, ) = viewModelScope.launch(context = coroutineDispatchers.io) { - val wrapper: BiometricPlatformWrapper = biometricPlatformWrapper ?: run { - logger.e { "BiometricPlatformWrapper is null — cannot show BiometricPrompt" } - return@launch - } CountingIdlingRes.increment() mutableStateFlow.update(BiometricEnrollResult::showLoading) try { @@ -81,7 +77,7 @@ class BiometricEnrollViewModel( title = title, subtitle = subtitle, negativeButton = negativeButton, - biometricPlatformWrapper = wrapper, + biometricPlatformWrapper = biometricPlatformWrapper, ) when (result) { is BiometricResult.Success -> withContext(coroutineDispatchers.main) { From cbe70c684da64633b6833592158a783d27a312b4 Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Thu, 30 Apr 2026 14:22:15 +0400 Subject: [PATCH 10/13] fix: wrap BiometricPlatformWrapper in remember block - Update `rememberBiometricPlatformWrapper` to use the `remember` composable across all platforms (Android, JVM, iOS, and WasmJs). - Prevent unnecessary re-instantiation of `BiometricPlatformWrapper` during recompositions. - Key the `remember` block on Android using the current `FragmentActivity` to ensure the wrapper stays in sync with the host activity. --- .../notedelight/ui/BiometricPlatformHelper.android.kt | 8 +++++--- .../notedelight/ui/BiometricPlatformHelper.ios.kt | 5 ++++- .../notedelight/ui/BiometricPlatformHelper.jvm.kt | 5 ++++- .../notedelight/ui/BiometricPlatformHelper.wasmJs.kt | 5 ++++- 4 files changed, 17 insertions(+), 6 deletions(-) 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 index f19204d28..51e92f270 100644 --- 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 @@ -2,10 +2,12 @@ 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 = BiometricPlatformWrapper( - activity = LocalActivity.current as FragmentActivity -) +actual fun rememberBiometricPlatformWrapper(): BiometricPlatformWrapper { + val fragmentActivity = LocalActivity.current as FragmentActivity + return remember(key1 = fragmentActivity) { BiometricPlatformWrapper(fragmentActivity) } +} 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 index 2748b30c3..b85859475 100644 --- 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 @@ -1,7 +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 = BiometricPlatformWrapper() +actual fun rememberBiometricPlatformWrapper(): BiometricPlatformWrapper = remember { + return@remember BiometricPlatformWrapper() +} 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 index 2748b30c3..b85859475 100644 --- 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 @@ -1,7 +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 = BiometricPlatformWrapper() +actual fun rememberBiometricPlatformWrapper(): BiometricPlatformWrapper = remember { + return@remember BiometricPlatformWrapper() +} 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 index 2748b30c3..b85859475 100644 --- 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 @@ -1,7 +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 = BiometricPlatformWrapper() +actual fun rememberBiometricPlatformWrapper(): BiometricPlatformWrapper = remember { + return@remember BiometricPlatformWrapper() +} From db615ba528d8cda8fd1aab05c2a05cc9dd4aaa5a Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Thu, 30 Apr 2026 14:26:50 +0400 Subject: [PATCH 11/13] docs: update feature support matrix in README - Add biometric authentication status to the platform feature compatibility table. --- README.md | 1 + 1 file changed, 1 insertion(+) 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). From c9916b864f8bb838d743ef6d4902cd9943998490 Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Thu, 30 Apr 2026 15:36:40 +0400 Subject: [PATCH 12/13] build: update iOS code signing and project paths - Update `.gitignore` to reflect the relocated `iosApp` directory under the `/app` path. - Switch iOS code signing from automatic to manual and configure SDK-specific identities and provisioning profiles. - Define SDK-specific development team settings in the Xcode project configuration. - Add a TODO in `SignInViewModel` to extract `AutofillManager` into an interactor, aimed at removing `androidx.compose` dependencies from the presentation module. --- .gitignore | 8 ++++---- app/iosApp/iosApp.xcodeproj/project.pbxproj | 11 +++++++---- .../UserInterfaceState.xcuserstate | Bin 15756 -> 0 bytes .../xcschemes/xcschememanagement.plist | 5 ----- .../presentation/signin/SignInViewModel.kt | 2 +- 5 files changed, 12 insertions(+), 14 deletions(-) delete mode 100644 app/iosApp/iosApp.xcworkspace/xcuserdata/artur.xcuserdatad/UserInterfaceState.xcuserstate delete mode 100644 app/iosApp/iosApp.xcworkspace/xcuserdata/artur.xcuserdatad/xcschemes/xcschememanagement.plist 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/app/iosApp/iosApp.xcodeproj/project.pbxproj b/app/iosApp/iosApp.xcodeproj/project.pbxproj index db7b5e6a8..5c764890b 100644 --- a/app/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/app/iosApp/iosApp.xcodeproj/project.pbxproj @@ -460,10 +460,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = H7L7R3VNZ4; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = H7L7R3VNZ4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = iosApp/Info.plist; @@ -473,7 +475,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 8.5.4; + MARKETING_VERSION = 8.5.4; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -485,6 +487,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.softartdev.notedelight; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = NoteDelight_Development_Profile; SWIFT_OBJC_BRIDGING_HEADER = "iosApp-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -514,7 +517,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 8.5.4; + MARKETING_VERSION = 8.5.4; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/app/iosApp/iosApp.xcworkspace/xcuserdata/artur.xcuserdatad/UserInterfaceState.xcuserstate b/app/iosApp/iosApp.xcworkspace/xcuserdata/artur.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index e9e81c12086aff474ef2d94594338431de8a19ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15756 zcmcJ034Bw<_WvxmYue zM-WI61w|3WeZ!5%L!Sz+xIFiL-xZhtx%W101C{sw@ALV!kTiE@&YU?j=XSp-vvQUSKEKD?IXlPgYgz1pPmMVNZ`Z6GZ}YiAOTeFm@MYUw zylQI0B%w!Wb@p|75yhgo84Uqu5hM&r=t;XeOG47NBO-g4)nx)Q&pPdB}&BqGf0Wx&Sd0M61wcXf0ZYu0Wg7O=t_+ zif%@`&>d(m+K28z_oJuKKhP2MJbE3SKyRQo(L3l9^eOrbeTTkBKcHXGujn`Q7e<)F zu{Z(e-~wETjkpZk@EBZy&%u*%Jzjuacp-LU0ef&K_TpuD1-<|?d>LMkufaFqEjWaG z@lL!8-->U;ci{c_Ui<)l5sLl0=e;hNO^GGK%O(4#_2XB%c(JLSiK4WHdR8j3Hym+2kBjOD2-3q@FaB z7Sc)tvWT>i#bgO-Cth+1xsG$*p`Xl{`{!CADh{GJ=;yDGE!10`#OW{(v5nLu$$Q5zLoSrjq z<=klQEN%=pmaF8dxLU4(Yvg8dE^Z+g;JUe`+%j%C*TbF9t>7-;R&qgZ6}N`l$X&_Z zz}+|_r?b1g{WwZQN~A(ON=B(Or#6%<5qkV{;Jr@+nsQp&-F|-vsZkOmjE0Z~r7(`& z$P;Dx; z*VwJ4WmPqf@Wdlf0kZ8y=_mtbqAZk+v}hz6g>)zf<)S>4&!Si~i(#=Wj>R(tOJIpi z$yAJI>b?Pgv@8}n1XyH!cW0I0 z4|qD=0YDH58UlbB-9DkouV7zx;^c!K*!nMKDeX5cF|NpXcbxk zV#0jP^mMj*mjNru)t&CFo>%%LDQffs96GJCa)$Z<_4(2P(ZCUF||ZXsr`r&>sM5^71O&en2~My|9RS zq08%oZB;c0?XWz1@N|{%j@Y3_@&+eh^@z@^Hh!M_MjRx0iAr2<1Ng=t*; z^hKU_VYJa`oZ$zSjcy;v+vx0U^?5z54T0`fkGIa}m7v==)f*71gmzEc;(*cJ)n)W} z{m%ZklI1PlR-p^}9z(Oy95ffrnv&zRZ(TOWev`Lm`rOp}blU|!u4 zUuiCW+;W3N(a`Q~1NKY~yQ8wqY&TeIs+@*WOQqdVUS4K1SgRd&OL=KUncZq17wG8{ z#NubB(>sRO+lTRF|4TS#6aS z2B*VWZZO#`HPz)#M~$Q0A@e#U*)EA}=4oUr%{5guCX>}rQEfFDO6?Y>p}eBXX0X_y z!Cp~SUg5Bvfo!)#Hv2TPmU4TIsmxMiuvV5;fMfw=oOXMSp`y}asi|^QRa%_pGmt%B zB0KUlvQB4}$>uPb4V6G2xUI4noEBTD!DK5hbC@bC9p+NY8OUBFk=30>)@CX*mzfM7S z6;^}IVzS%IO_r*v>Y6irq$!RHdR2j^bH4fkwcFR!;LU340YpOv4&a(1T zWh-mXdb9zQR>Y*95L%Dshp~Mlx(3;HqASr=XcM}c6|rKbXNH~VT67({9^Jr7SSg#t zT18AgYnG^xC16k{c>*Po&|K0G@O4Yb+6w3Fr)!(ba`V6wMGGBkb}U++Y9+gX7u{=3nBFc(uBrgR5Hi&5nP;P!Tc z`uC!{(E&7n-qePHXW=;yqI;2T`}P4Q9zc(Zn^g;_{V;Gk0+70lrS3$Jp~ul7^aQgr z2diZh#Q_6geONk5HWxo_xxpjLz#vF=)zI4K4*(oCx4S!+^gl2<%dF1IGN*`+WpK_+ zrGPk<PK-^ zKd~xS&?K8zIEO=uK(w(Z(eExkGh$CpJ>0$M5A-LR|DU?SELBa7rw$%^?!JX#j7h|K z$5A*s@RAJ8WHVS3Yi2DG8ieyD4Vv=*r$Ob;^71N+(+SY* z1eTFw@*`Jxt!8h74# z6Xu7)V(7yD!5W7HxSP4yLS~UkF7H>dGjp;MUl=LPAiiX1X|Bd=hTf?S_)6dePWi)d zs>?$7D!hpaY*D|7z}MpIBImdfZ;pJh6>meSJMhhH@eaJ5dDyx0!J)9 zXduKB)b;Dyqm$S&dHFBmV@+Vj~v!k@sBG^pLE>G8DZ>Qk(yWGAG zTd6$x+xXv7f~?Q^koUP4zm4CKVkY@>cyaIH55*JqKK_7Rz*dIvNBCoQA-hOa-2ase z7eCrw4Q_UYrOMzavsZ&VTv~2$*ukkVRhE{OnM{>7a5wsR_j@|JA)@s9`j#Nc_6z*H zq?L6$&ewoUrDtEu&p^({wQO0K|L^ecD0Mgf9{+%U#6RJm@h|vS{2M;Wg6v{;3A>c7 zVwbVi>~gkdH!8z_A_M-DU_uBb93&;yvUQM*SjX104eScG5k8+g$K$Vt5Y+>28-(fs z$;+q$m!n0f12!Rd(Ba}!2S2X$&-6f0EQlTmFE=z)O=@!cJZ>@d5a?GC3Myt;}C zlcCH4;fd4is5Drdr8bM*UJd(Xo*|u{d0`qBTS@6GPo2Bft~Zo8J~pn==k*34{mu_pBqVDxG7F1L zqt6c;2ELQ`MPvzS=ObV4pEwFL51?LtEj zB;-0~)QWRdc-vc#D~t7p5~GVxljj|v+kbXPoZ2#IfvHIhU&^sqab@PR^j?d#R8Fb( z+UyP~t(wA;!NrhCh>PeS?Bcak8!8(`Mrs7NnBi;?0+9G@^AGAWEZk*A?F_%WZD91V zROQ*@cZ`Fqtwx-?qB=cPStVw0Lp2ka8ZxyoYdTO5Qx3>H zWF*!>@uw2c$LB+t2mC_37GHs{#G9mI&t@q1+>EzFiRX1NYro=?_z(OSiH2g10tz`i zlyEdqzA1&`OdXVGW>hTI-Fq7{kQJy5UN0ow;yXAZ_kjb_ z&hCdU;yVZ`1(ql0_G|q$P#bChYcF&z=zm({1ITjm26zBCHpSBhhDB(VEQY_v=k1u< zP!HJ)uTM-uNu?8U17&k2lWlZ2SGavM-F~S;(k{&O1Qs`dCP0;9rIju|8wf~-Q}`P){P6z8 zG1b87|iFOrfjp_taX^QW>a|?yqHS0CipemEe`nPDDAhB8_4y@wwqi* zHj*pJRb&&nnp{J!CD*YB*n{jL_Aq;dJ<1+qkF!JIACeo%W^xl~&{lFYY|M7ph9}s& z>^=4o`#6Yau=XIty09%g@Z7N2yKJg^si#dg<>I6Mk4`AUKc zW7ppb06oFy?e3BrWUnp#ZwNsK*B|EVVRA_1>k;xOd5k>H{=ts0f3l-H$rI#B@)UW3 zJ^Dvo)&^n_wl1`YP zXUTIY6*908e2T7W1lSIKdP0Lu4z0+H4PaBGMw}%ehLS zwHBWE7l3C2`dR1o!>RLjTBT&-TkfkuF^R`w{@jnl# z9HlB@fv~*G-6`I`kZ4Q83vd1Gzz#KxPD8;GnZ@z#DS?7wQx~dhe5uAPxtE{ULjqy%GY& z`~;NpRq^QO4y{R&#tjr={&p_8pQv`=)eTLmWUx|xMSeiGTgcboIDAXKBj2;v*$MUr zdy~C&3;7XH@n`Z27^}CzK>eG&BbzBr3!FK37o5$3#lvhOv;*aSHIg`~=#dmRSrAVe z6xt(3h>pSF5q(w(O6LJd!qQOPDV^lP`XGFOUXop=ShCCV-<TF9lWT&Wx1HC{S?efvSsO#K4{3B1WV`7wI-HK6=`@38 z(kz-ywR9x=gni0BW1q7x*q7`p_BH#4eY=P1ke=q!Jep4nXdx}4#Z=F}gDV%`v%i8E z1#w~!+k$v{5Z4EBBeN`+;$H6QfUp9*UtrC}7gY$5-w?#;8nPFDZ>NN(VqL#)A7qmx zqpUh?WEvoX42U{8XK=Hw%jXdz^RN|@?sBz80v>EexLI&ZWF$sFaLKU6efK`3X=F>O z?VA8@gLuSnOq>`(Z*iK*?jCP9WLvah+jI)yS`qQ;0Q;azAK`%;7Li=}c@*A&(R2*t z>*!hRhaGe*`%&C{HK0ZRiY3UEx6p|XhG+$?WIwUr`&~g=LnlI(lTM)L&|3C0`-T15 zODCZ+I)(iPzQsvM#sawk4VWi>8gPGj_$Uf?iW`boaFs%s4PucOrW?OL^1RNVvrn68 z4l>Yr><`e=yiQ$G#BqPjsbmdzQMY=*=}5OZuv z5JwG8fz$Kp3KyRNk{Ylua%|Xlf;_$uy+E`xXY7-F(O>e+IbAbHG~+T~L97Vkco$#(KVQI+`B_gl z%mr@3+t*94pc~Qr!H?hs)j|FW?k2(H)er~(%8Dzwif(f81t7ABT^f*Iq;CelUJLaFdR-7J2XyO3x*g(Ax|!ZYx6rNhX1XniRYA-Lu{wy8f;c&dHM>9qLbMlt zZxI8mAWjM5ROw%uY`HSQjqh%66`lDyh_C8}^SU8m0tA#2iF3u{Hp$c33KnG$y3Oyy zA@DZM)?&Z}`Li5L^qKDL1#faQ*b=F-Dwya8({? zhPfOtvHeLa(Doo66~s9~oGD4PRMdYsB)UlOdx^f>2Y#;y@yH;~8iYw&`7pjAito)J z&OQZU!hmu{?T6%9i?^c$?!UDQ@}0L5@zNW3IRE>lnXtY3jD95=fzRm|^vfX54dT2Y z&hMpP({JdvL0k~Tg+Xi)O~Fw2FwStXVyN!nQ+FzSf%X3HE51WtO@Q04^cPXazXoy9 zpu#Qvo&G`pL=cJ_f>l!JQ-A&%psg197zjUg_Eiw$B^5GwUU z-ooG#fqVHD1^~Ha#jFuGP{NdxxMaCn3%6Ix?IuUH4Q}Sy%FH$cRQlkCVs%A@!C|vh zT5Mvv#R=&q09-DO8wTp#?+XlqgAg}d1lJ+kq_fMVOShE5^Jj572*9~)PRottKsoF| z>!i$q<-uQ0D?bK}AFV@jbFP z7UrOdn{(RWx$@u%eS=RIv70*=a*tdy*TS`O0=I~3;}&zCAg&GKi9tLmh$jc}lpvlO z#M6Sf4swvvFJvF(Ul70eD`p?%U)Tn*q}A_Yg|GXG{^Mj%vrqK!rAyJ_7(z@%4%u5w zu!0fj=!)!L^mqO1YO5NdErPiI?>bKr0`69@K#|==A7f}=L-OYZf>=W7F z_;;k?s-u{t5p7fCh`;5%KP-$`YEoZBBPv{@S5l?p+=bjlkV$I8yEq2MaB4%JPa0S} zS?FvNV;d-l^gojnC5Rhj%($4l1a5ElH-@-N!MPjsAbjdB<5ox9AK}(=>qSLa7sO3F zq>8zynUcCp>um4o^KL{9lkZ}Ob9>xX9NaVwahtfSgLqaD&kl(vOgdFSAf=zcWVo&_ zv{tlxTb6)QX3whcZxM6NkQx)y*!=+p6k>*>Q2-TY_&(1#D8e+Md1wKAJ0t@FUNap- zDy5K zztG>ny8H>>?;wEl(OfKi$0LDL!uL9A;2Rw0b0O{??w{Pp+{vhwVs`$0>>*KG8zdHWf`0L|ujNcW%JO1|g zJLC7o?~ngi{HyV=#h-|OKmJz*QY0!yDAE-fiY!I3qEum1I25B5V-(ej35r_9B*hfP zG{ro{0>wf_v*I4bA;nXQXB0;iM-|U0PAJ}0ys!9B@v-77#W#xY6hA0_O2|!UOt>&% zb;8<&^$AxbY)jaka3JBaghL5WCOn;RI59mjC()c}OLQcTP8^r$Osq_-PMnh1m^eRi zVPbP)YvPi`VB*@uJ&AWGzL@xN;vdR*r9zpYR4UVz8Ols$woj*C{tBH!81E-mKiN3@LXie^OCZ zv?^AmP$jBVsti@OYNRShm8U9DRjH<{8dXiI*{Zp!`KohO9V)NNrwXW+sxDDorn+3U zPPIX`QME<2Rdus!yK1j$pK8DApz1!=1FC0K&#PWk9ap`qdR6s~>OIv5s*hBks6OL0 z{8+w$U&y=pX1K1jE+OO_bFIS(hzCgWJeU*B%dYgKOI;6f`eW!Y#dcXQH^%Lr+)Q8na z)JN4Xsb5vUu6{%Pf%<#(Z%Hvp$|OE1DM^!*o|KuCois8@msFK>LDDTr`;!hP-Iw%W z(!)uQCLK5dz0VNjL_&bdQFMOt{JbX&`i@bYg#pnG>bLoYT7lOnl6n`6VR;I ztkJB~Y|w1fT&1~MbFJoj&5fFyG`lp9XkO8Lnu1bNQnFL*DRn8+Q|6>>Ou05ClyZB@ z{V9i1o=kZ<<#5W8lw&C`rM!~zTFQx(H&cF0LsbG zQa7dEn0iy{*3@mOyHamU-IIDp>Yb^trbVTh(?+L_Nvln(ORG<7OlwM;pXN$)r?sRB zX`N}^X&0tlmVRye_37Kw?@T|K{&4z{^jFefOFxnRX8POd@1(z%{&o7d>EEaSnErG6 zujwb#|Hv4XF(Ko;jGHocX6(wiE93r*2Q!Xj9LqSK@p8s{8Q*98nDKMQuNfyZ{>b<% z6KB#)O=fE5u*?ye8JSs`+RRazIhlExXJ<~yT$#Bm^W&_ztlX^eS z@~pL4>$9%Nx-x51)-_qTX6??pJ!@~)U0HW$9mu*j>;9~VvL4BLA?y9DpR?n$v$DUZsv|F_|Yqx73(*8qxRQsIv1?@5IOWLoszl=;7nKp9x zDD9}iQT3x{kGgo&$GUi3nl3|^rPJzib@{qNU9qk~w^VnHZins;-JQC9y8XI)boc5G z>7LX*tvjqcqC2X4PWM3$%1O;RE61I4ZqAaNj+~`A%W`^huFBb*b8F5WId|sl%Q={H zU(N$L59NHA8<(4uo1B}Ho0VIfJ3hA}w<@`yubAykGKu%g6aNKPo>aKQ2ElKPTUw@64Z=KQn(p{=)p` z{MP&>`5pP*{PXht`K$7;$iFgwcmBiqNAus$|EVCUAf;efL3%+}!N`J~f~EpjL34pn z&{hyAxTs)N!Q};O3pNy7Q?Rw*;eta2?-YDm@KeDr1t$ysD2y)5C>&X6FC1Susc>rH z^ui5=*A<2ecNZQkJY4uf;mIOYR9sYAWG@K_m@0W@@UDECC`)` zDLGnlyyQg5`z0Thd{Xk65g8MWN~6lCHad(><1FKRW5Br5c%hLQHyE!o-e}xn+-3|J zZ#Uj)yxVxdc(3t(<00eg#t)2N8BdxxQ;aFzlxX5j!%gX?OjEXLq)BITniiOXrp=~X zO?ymxP5Vp-Ob1OzOvg;GnogMBGX2~1h3R|KkEWkZznYPmnxo9I=6G|mIm29NHksk( zrrBm5YaS1`?5fQZ%+t*a%wDt4+-+WNUSYn_95i2QUTwa?yxF|Pe6xAGIb_~x-etbc zyvO{SCDu}AX|-&$JZAaUs - - - - 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 7bbc23afe..9ddee1c67 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 @@ -29,7 +29,7 @@ class SignInViewModel( 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) From e0136b9d443c56264da852e43eeb9ac74fdc2734 Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Fri, 1 May 2026 02:17:31 +0400 Subject: [PATCH 13/13] Refactor Sign-in state to explicit loading/error fields and rename ActivityProvider to BiometricPlatformWrapper (#719) * core/signin: simplify state and update biometric docs * refactor: update SignInViewModel error assertions in tests - Update `SignInViewModelTest` to use the `errorType` property for verifying error states. - Replace subclass-based assertions for `EmptyPass` and `IncorrectPass` with `ErrorType.EMPTY_PASSWORD` and `ErrorType.INCORRECT_PASSWORD` equality checks. * refactor: improve sign-in state handling and simplify ViewModel logic - Nest the `ErrorType` enum within the `SignInResult` class. - Add helper functions to `SignInResult` for common state transformations, such as `showLoading`, `hideErrors`, and specific error states. - Refactor `SignInViewModel` to use these helper functions for state updates, improving readability. - Remove the internal `SignInInternalResult` sealed interface and its associated mapping logic. - Update `SignInScreen` and `SignInViewModelTest` to reference the relocated `SignInResult.ErrorType`. * feat: add confirmation dialog for disabling biometric sign-in - Implement `BiometricDisableConfirmationDialog` and its corresponding `BiometricDisableViewModel` to handle user confirmation. - Introduce `DisableBiometricUseCase` to encapsulate the logic for clearing stored biometric credentials. - Refactor `SettingsViewModel` to trigger the confirmation dialog and await the result via a `Channel` before clearing stored passwords. - Register the new dialog in the navigation graph and update dependency injection modules. - Add localized string resources for the confirmation dialog in English and Russian. - Expand test coverage with UI tests for the new dialog and unit tests in `SettingsViewModelTest` to verify the confirmation flow. - Update test infrastructure with new test tags and helper methods for the biometric disable screen. * refactor: biometric interactor and enhance UI test coverage - Convert `BiometricInteractor` from an `expect` class to an interface and provide platform-specific implementations (`AndroidBiometricInteractor`, `IosBiometricInteractor`, etc.). - Remove `DisableBiometricUseCase` and move its functionality and the biometric disable dialog channel into `BiometricInteractor`. - Implement `TestBiometricInteractor` and a corresponding Koin module to support mocked biometric states during UI tests. - Add `BiometricSignInTestCase` and `BiometricSettingsTestCase` to provide reusable test logic for biometric flows. - Introduce `BiometricEnrollDialog` semantics wrapper and update `SettingsTestScreen` and `SignInScreen` with new biometric test tags. - Update `SettingsViewModel` and dependency injection modules to reflect the new `BiometricInteractor` structure and the removal of the use case. - Add Android instrumentation tests (`BiometricSignInTest`, `BiometricSettingsTest`) to verify biometric integration on the platform. --- .../notedelight/ui/BiometricSettingsTest.kt | 54 ++++++++++++ .../notedelight/ui/BiometricSignInTest.kt | 55 ++++++++++++ .../adaptive/AdaptiveInteractorTest.kt | 2 +- .../settings/SettingsViewModelTest.kt | 55 ++++++++++++ .../signin/SignInViewModelTest.kt | 14 +-- .../notedelight/navigation/AppNavGraph.kt | 3 + .../settings/SettingsViewModel.kt | 14 ++- .../presentation/signin/SignInResult.kt | 20 ++--- .../presentation/signin/SignInViewModel.kt | 33 +++---- core/test/ui/build.gradle.kts | 3 +- .../notedelight/di/uiTestModules.kt | 9 +- .../kotlin/com/softartdev/notedelight/ext.kt | 16 ++-- .../interactor/TestBiometricInteractor.kt | 54 ++++++++++++ .../softartdev/notedelight/ui/BaseTestCase.kt | 9 ++ .../ui/cases/BiometricSettingsTestCase.kt | 87 +++++++++++++++++++ .../ui/cases/BiometricSignInTestCase.kt | 35 ++++++++ .../ui/screen/SettingsTestScreen.kt | 7 ++ .../notedelight/ui/screen/SignInScreen.kt | 8 +- .../BiometricDisableConfirmationDialog.kt | 19 ++++ .../ui/screen/dialog/BiometricEnrollDialog.kt | 31 +++++++ .../notedelight/di/uiModules.android.kt | 5 +- .../composeResources/values-ru/strings.xml | 3 + .../composeResources/values/strings.xml | 3 + .../kotlin/com/softartdev/notedelight/App.kt | 6 +- .../notedelight/di/sharedModules.kt | 2 + .../BiometricDisableConfirmationDialog.kt | 64 ++++++++++++++ .../notedelight/ui/signin/SignInScreen.kt | 12 +-- .../softartdev/notedelight/util/TestTags.kt | 1 + .../notedelight/di/uiModules.ios.kt | 3 +- .../notedelight/di/uiModules.jvm.kt | 3 +- .../notedelight/di/uiModules.wasmJs.kt | 3 +- feature/biometric/domain/README.md | 20 ++--- ...droid.kt => AndroidBiometricInteractor.kt} | 12 +-- .../interactor/BiometricInteractor.kt | 8 +- ...actor.ios.kt => IosBiometricInteractor.kt} | 12 +-- ...actor.jvm.kt => JvmBiometricInteractor.kt} | 13 +-- ...or.wasmJs.kt => WebBiometricInteractor.kt} | 13 +-- .../biometric/BiometricDisableViewModel.kt | 29 +++++++ 38 files changed, 641 insertions(+), 99 deletions(-) create mode 100644 app/android/src/androidTest/java/com/softartdev/notedelight/ui/BiometricSettingsTest.kt create mode 100644 app/android/src/androidTest/java/com/softartdev/notedelight/ui/BiometricSignInTest.kt create mode 100644 core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/interactor/TestBiometricInteractor.kt create mode 100644 core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/cases/BiometricSettingsTestCase.kt create mode 100644 core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/cases/BiometricSignInTestCase.kt create mode 100644 core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/dialog/BiometricDisableConfirmationDialog.kt create mode 100644 core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/dialog/BiometricEnrollDialog.kt create mode 100644 core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricDisableConfirmationDialog.kt rename feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/{BiometricInteractor.android.kt => AndroidBiometricInteractor.kt} (96%) rename feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/{BiometricInteractor.ios.kt => IosBiometricInteractor.kt} (96%) rename feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/{BiometricInteractor.jvm.kt => JvmBiometricInteractor.kt} (59%) rename feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/{BiometricInteractor.wasmJs.kt => WebBiometricInteractor.kt} (59%) create mode 100644 feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricDisableViewModel.kt 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/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 cfa7f9733..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 @@ -27,10 +27,10 @@ import com.softartdev.notedelight.presentation.settings.SettingsCategoriesAction import com.softartdev.notedelight.presentation.settings.SettingsCategoriesViewModel import com.softartdev.notedelight.presentation.settings.SettingsViewModel import com.softartdev.notedelight.repository.SafeRepo +import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase import com.softartdev.notedelight.usecase.note.CreateNoteUseCase import com.softartdev.notedelight.usecase.note.DeleteNoteUseCase import com.softartdev.notedelight.usecase.note.SaveNoteUseCase -import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase import com.softartdev.notedelight.usecase.settings.AppVersionUseCase import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase import com.softartdev.notedelight.usecase.settings.ImportDatabaseUseCase 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 1df324f65..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 @@ -163,6 +163,52 @@ class SettingsViewModelTest { Mockito.verifyNoMoreInteractions(mockRouter) } + @Test + fun changeBiometricEnableShowsEnrollDialog() = runTest { + settingsViewModel.onAction(SettingsAction.ChangeBiometric(true)) + + Mockito.verify(mockRouter).navigate(route = AppNavGraph.BiometricEnrollDialog) + Mockito.verifyNoMoreInteractions(mockRouter) + } + + @Test + fun changeBiometricDisableShowsConfirmationDialogAndKeepsStoredPasswordOnCancel() = runTest { + stubBiometricEnabled() + settingsViewModel.updateSwitches() + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(settingsViewModel.stateFlow.value.biometricEnabled) + + settingsViewModel.onAction(SettingsAction.ChangeBiometric(false)) + Mockito.verify(mockRouter).navigate(route = AppNavGraph.BiometricDisableConfirmationDialog) + + BiometricInteractor.disableDialogChannel.send(false) + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(settingsViewModel.stateFlow.value.biometricEnabled) + Mockito.verify(mockBiometricInteractor, Mockito.never()).clearStoredPassword() + Mockito.verifyNoMoreInteractions(mockRouter) + } + + @Test + fun changeBiometricDisableClearsStoredPasswordAfterConfirmation() = runTest { + stubBiometricEnabled() + settingsViewModel.updateSwitches() + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(settingsViewModel.stateFlow.value.biometricEnabled) + + settingsViewModel.onAction(SettingsAction.ChangeBiometric(false)) + Mockito.verify(mockRouter).navigate(route = AppNavGraph.BiometricDisableConfirmationDialog) + + BiometricInteractor.disableDialogChannel.send(true) + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(settingsViewModel.stateFlow.value.biometricEnabled) + Mockito.verify(mockBiometricInteractor).clearStoredPassword() + Mockito.verifyNoMoreInteractions(mockRouter) + } + @Test fun changePasswordChangePasswordDialog() = runTest { Mockito.`when`(mockSafeRepo.databaseState).thenReturn(ENCRYPTED) @@ -265,4 +311,13 @@ class SettingsViewModelTest { } assertFalse(settingsViewModel.stateFlow.value.fileListVisible) } + + private fun stubBiometricEnabled() { + Mockito.`when`(mockSafeRepo.databaseState).thenReturn(ENCRYPTED) + Mockito.`when`(mockLocaleInteractor.languageEnum).thenReturn(LanguageEnum.ENGLISH) + runBlocking { + Mockito.`when`(mockBiometricInteractor.canAuthenticate()).thenReturn(true) + Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(true) + } + } } 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 70e12706e..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 @@ -39,7 +39,9 @@ class SignInViewModelTest { private val mockRouter = Mockito.mock(Router::class.java) private val mockAutofillManager = Mockito.mock(AutofillManager::class.java) private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java) - + private val biometricPlatformWrapper: BiometricPlatformWrapper = BiometricPlatformWrapper( + activity = Mockito.mock(FragmentActivity::class.java) + ) private lateinit var signInViewModel: SignInViewModel @Before @@ -91,7 +93,7 @@ class SignInViewModelTest { assertEquals(SignInResult(), awaitItem()) signInViewModel.onAction(SignInAction.OnSignInClick(pass = StubEditable(""))) - assertTrue(awaitItem().state is SignInResult.State.Error.EmptyPass) + assertEquals(SignInResult.ErrorType.EMPTY_PASSWORD, awaitItem().errorType) cancelAndIgnoreRemainingEvents() } @@ -105,7 +107,7 @@ class SignInViewModelTest { val pass = StubEditable("pass") Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(false) signInViewModel.onAction(SignInAction.OnSignInClick(pass)) - assertTrue(awaitItem().state is SignInResult.State.Error.IncorrectPass) + assertEquals(SignInResult.ErrorType.INCORRECT_PASSWORD, awaitItem().errorType) cancelAndIgnoreRemainingEvents() } @@ -147,7 +149,7 @@ class SignInViewModelTest { Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true) signInViewModel.stateFlow.test { assertEquals(SignInResult(), awaitItem()) - signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c", BiometricPlatformWrapper(Mockito.mock(FragmentActivity::class.java)))) + signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c", biometricPlatformWrapper)) Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Main) cancelAndIgnoreRemainingEvents() } @@ -159,7 +161,7 @@ class SignInViewModelTest { .thenReturn(DecryptedPasswordResult.Unavailable) signInViewModel.stateFlow.test { assertFalse(awaitItem().biometricVisible) - signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c", BiometricPlatformWrapper(Mockito.mock(FragmentActivity::class.java)))) + signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c", biometricPlatformWrapper)) Mockito.verify(mockBiometricInteractor).clearStoredPassword() cancelAndIgnoreRemainingEvents() } @@ -172,7 +174,7 @@ class SignInViewModelTest { .thenReturn(DecryptedPasswordResult.Failure(errorMessage)) signInViewModel.stateFlow.test { assertEquals(SignInResult(), awaitItem()) - signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c", BiometricPlatformWrapper(Mockito.mock(FragmentActivity::class.java)))) + signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c", biometricPlatformWrapper)) Mockito.verify(mockSnackbarInteractor).showMessage(SnackbarMessage.Simple(errorMessage)) cancelAndIgnoreRemainingEvents() } 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 c4e4e85df..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 @@ -46,6 +46,9 @@ sealed interface AppNavGraph { @Serializable data object BiometricEnrollDialog : AppNavGraph + @Serializable + data object BiometricDisableConfirmationDialog : AppNavGraph + @Serializable data class ErrorDialog(val message: String?) : AppNavGraph } 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 bb40656a9..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 @@ -144,8 +144,18 @@ class SettingsViewModel( if (checked) { router.navigate(route = AppNavGraph.BiometricEnrollDialog) } else { - biometricInteractor.clearStoredPassword() - mutableStateFlow.update { it.copy(biometricEnabled = false) } + router.navigate(route = AppNavGraph.BiometricDisableConfirmationDialog) + val disableBiometric: Boolean = withContext(coroutineDispatchers.io) { + BiometricInteractor.disableDialogChannel.receive() + } + if (disableBiometric) { + withContext(coroutineDispatchers.io) { + biometricInteractor.clearStoredPassword() + } + mutableStateFlow.update { it.copy(biometricEnabled = false) } + } else { + logger.d { "Don't disable biometric" } + } } } catch (e: Throwable) { handleError(e) { "error toggling biometric" } 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 4e1eda7ad..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,18 +1,16 @@ package com.softartdev.notedelight.presentation.signin data class SignInResult( - val state: State = State.Form, + val loading: Boolean = false, + val errorType: ErrorType? = null, val biometricVisible: Boolean = false, ) { - sealed interface State { - data object Form : State + enum class ErrorType { EMPTY_PASSWORD, INCORRECT_PASSWORD } - data object Progress : State - - sealed interface Error : State { - data object EmptyPass : Error - - data object IncorrectPass : Error - } - } + fun showLoading(): SignInResult = copy(loading = true) + fun hideLoading(): SignInResult = copy(loading = false) + fun hideBiometric(): SignInResult = copy(biometricVisible = true) + fun showEmptyPasswordError(): SignInResult = copy(errorType = ErrorType.EMPTY_PASSWORD) + fun showIncorrectPasswordError(): SignInResult = copy(errorType = ErrorType.INCORRECT_PASSWORD) + fun hideErrors(): SignInResult = copy(errorType = null) } 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 9ddee1c67..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 @@ -55,7 +55,8 @@ class SignInViewModel( biometricPlatformWrapper: BiometricPlatformWrapper, ) = viewModelScope.launch { CountingIdlingRes.increment() - mutableStateFlow.update { it.copy(state = SignInResult.State.Progress) } + mutableStateFlow.update(SignInResult::hideErrors) + mutableStateFlow.update(SignInResult::showLoading) try { when (val res: DecryptedPasswordResult = biometricInteractor.decryptStoredPassword( title = title, @@ -63,56 +64,48 @@ class SignInViewModel( negativeButton = negativeButton, biometricPlatformWrapper = biometricPlatformWrapper, )) { - is DecryptedPasswordResult.Success -> mutableStateFlow.update { - it.copy(state = signInInternal(res.password)) - } - is DecryptedPasswordResult.Cancelled -> mutableStateFlow.update { - it.copy(state = SignInResult.State.Form) - } + is DecryptedPasswordResult.Success -> signInInternal(pass = res.password) + is DecryptedPasswordResult.Cancelled -> Unit is DecryptedPasswordResult.Unavailable -> { biometricInteractor.clearStoredPassword() - mutableStateFlow.update { - it.copy(state = SignInResult.State.Form, biometricVisible = false) - } + mutableStateFlow.update(SignInResult::hideBiometric) } is DecryptedPasswordResult.Failure -> { logger.e { res.message } snackbarInteractor.showMessage(SnackbarMessage.Simple(res.message)) - mutableStateFlow.update { it.copy(state = SignInResult.State.Form) } } } } catch (error: Throwable) { logger.e(error) { "Error during biometric sign in" } router.navigate(route = AppNavGraph.ErrorDialog(message = error.message)) - mutableStateFlow.update { it.copy(state = SignInResult.State.Form) } } finally { + mutableStateFlow.update(SignInResult::hideLoading) CountingIdlingRes.decrement() } } private fun signIn(pass: CharSequence) = viewModelScope.launch { CountingIdlingRes.increment() - mutableStateFlow.update { it.copy(state = SignInResult.State.Progress) } + mutableStateFlow.update(SignInResult::hideErrors) + mutableStateFlow.update(SignInResult::showLoading) try { - val nextState: SignInResult.State = signInInternal(pass) - mutableStateFlow.update { it.copy(state = nextState) } + signInInternal(pass) } catch (error: Throwable) { logger.e(error) { "Error during sign in" } autofillManager?.cancel() router.navigate(route = AppNavGraph.ErrorDialog(message = error.message)) - mutableStateFlow.update { it.copy(state = SignInResult.State.Form) } } finally { + mutableStateFlow.update(SignInResult::hideLoading) CountingIdlingRes.decrement() } } - private suspend fun signInInternal(pass: CharSequence): SignInResult.State = when { - pass.isEmpty() -> SignInResult.State.Error.EmptyPass + private suspend fun signInInternal(pass: CharSequence) = when { + pass.isEmpty() -> mutableStateFlow.update(SignInResult::showEmptyPasswordError) checkPasswordUseCase(pass) -> { autofillManager?.commit() router.navigateClearingBackStack(AppNavGraph.Main) - SignInResult.State.Form } - else -> SignInResult.State.Error.IncorrectPass + else -> mutableStateFlow.update(SignInResult::showIncorrectPasswordError) } } 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/src/androidMain/kotlin/com/softartdev/notedelight/di/uiModules.android.kt b/core/ui/src/androidMain/kotlin/com/softartdev/notedelight/di/uiModules.android.kt index d858274e4..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 @@ -3,6 +3,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.AndroidBiometricInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarInteractorImpl import org.koin.core.module.Module @@ -13,5 +14,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) - singleOf(::BiometricInteractor) -} \ No newline at end of file + single { AndroidBiometricInteractor(get()) } +} diff --git a/core/ui/src/commonMain/composeResources/values-ru/strings.xml b/core/ui/src/commonMain/composeResources/values-ru/strings.xml index 5f7666ab4..a2b7e424d 100644 --- a/core/ui/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-ru/strings.xml @@ -93,6 +93,9 @@ По биометрии Включить вход по биометрии Подтвердите пароль, чтобы разрешить вход по биометрии. + Отключить вход по биометрии? + Чтобы снова включить вход по биометрии, потребуется заново ввести пароль. + Отключить Вход по биометрии отключён — снова включите его в настройках. Не удалось пройти биометрию Биометрия недоступна diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index ee9cd0670..3a7e639f9 100644 --- a/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/core/ui/src/commonMain/composeResources/values/strings.xml @@ -93,6 +93,9 @@ 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 287d861c4..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,7 @@ 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 @@ -104,6 +105,9 @@ fun App( dialog { BiometricEnrollDialog(biometricEnrollViewModel = koinViewModel()) } + dialog { + BiometricDisableConfirmationDialog(biometricDisableViewModel = koinViewModel()) + } dialog { backStackEntry: NavBackStackEntry -> ErrorDialog( message = backStackEntry.toRoute().message, @@ -121,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 c342184bd..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,7 @@ 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 @@ -78,6 +79,7 @@ val viewModelModule: Module = module { 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/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/signin/SignInScreen.kt b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/signin/SignInScreen.kt index 1c062f878..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 @@ -71,14 +71,14 @@ fun SignInScreen(signInViewModel: SignInViewModel) { signInViewModel.onAction(SignInAction.RefreshBiometric) } SignInScreenBody( - showLoading = signInResultState.value.state is SignInResult.State.Progress, + showLoading = signInResultState.value.loading, passwordState = passwordState, - labelResource = when (signInResultState.value.state) { - is SignInResult.State.Error.EmptyPass -> Res.string.empty_password - is SignInResult.State.Error.IncorrectPass -> 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.state is SignInResult.State.Error, + isError = signInResultState.value.errorType != null, biometricVisible = signInResultState.value.biometricVisible, onAction = signInViewModel::onAction, ) 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 d788a215e..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 @@ -83,6 +83,7 @@ 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 eb5818960..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 @@ -3,6 +3,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.IosBiometricInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarInteractorImpl import org.koin.core.module.Module @@ -13,5 +14,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) - singleOf(::BiometricInteractor) + singleOf(::IosBiometricInteractor) } 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 eb5818960..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 @@ -3,6 +3,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.JvmBiometricInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarInteractorImpl import org.koin.core.module.Module @@ -13,5 +14,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) - singleOf(::BiometricInteractor) + singleOf(::JvmBiometricInteractor) } 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 eb5818960..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 @@ -3,6 +3,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.WebBiometricInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarInteractorImpl import org.koin.core.module.Module @@ -13,5 +14,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) - singleOf(::BiometricInteractor) + singleOf(::WebBiometricInteractor) } diff --git a/feature/biometric/domain/README.md b/feature/biometric/domain/README.md index 6f314f985..f8656bde3 100644 --- a/feature/biometric/domain/README.md +++ b/feature/biometric/domain/README.md @@ -4,7 +4,7 @@ Biometric authentication domain module — platform-agnostic contract plus platf ## Overview -Provides the `BiometricInteractor` expect class and its platform actuals, as well as `BiometricResult` / `DecryptedPasswordResult` domain types, and the `ActivityProvider` expect class used to pass the Android host Activity to the biometric prompt from Compose. +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 @@ -19,19 +19,19 @@ expect class BiometricInteractor { title: String, subtitle: String, negativeButton: String, - activityProvider: ActivityProvider, + biometricPlatformWrapper: BiometricPlatformWrapper, ): BiometricResult suspend fun decryptStoredPassword( title: String, subtitle: String, negativeButton: String, - activityProvider: ActivityProvider, + biometricPlatformWrapper: BiometricPlatformWrapper, ): DecryptedPasswordResult suspend fun clearStoredPassword() } ``` -The `activityProvider` parameter is created in `@Composable` functions via `rememberActivityProvider()` (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. +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` @@ -58,15 +58,15 @@ sealed interface DecryptedPasswordResult { `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`. -### `ActivityProvider` +### `BiometricPlatformWrapper` ```kotlin -expect class ActivityProvider -// Android actual: actual class ActivityProvider(val activity: FragmentActivity) -// iOS/JVM/wasmJs: actual class ActivityProvider (empty stub) +expect class BiometricPlatformWrapper +// Android actual: actual class BiometricPlatformWrapper(val activity: FragmentActivity) +// iOS/JVM/wasmJs: actual class BiometricPlatformWrapper (empty stub) ``` -Created from a Composable using `rememberActivityProvider()` (in `core:ui`) and stored as a var property on the ViewModel. Passed to `encryptAndStorePassword` / `decryptStoredPassword` so that the Android implementation can instantiate `BiometricPrompt`. +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 @@ -96,7 +96,7 @@ Created from a Composable using `rememberActivityProvider()` (in `core:ui`) and **Key design decisions**: - `BiometricPrompt.authenticate()` must run on the main thread — `runPrompt()` uses `withContext(Dispatchers.Main.immediate)`. -- `ActivityProvider` is supplied from the composable layer (`rememberActivityProvider()` 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. +- `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): diff --git a/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/AndroidBiometricInteractor.kt similarity index 96% rename from feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt rename to feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/AndroidBiometricInteractor.kt index 6885719ac..f1cb60cc2 100644 --- a/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt +++ b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/AndroidBiometricInteractor.kt @@ -22,18 +22,18 @@ import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec import kotlin.coroutines.resume -actual class BiometricInteractor(context: Context) { +class AndroidBiometricInteractor(context: Context) : BiometricInteractor { private val logger = Logger.withTag("BiometricInteractor") private val appContext: Context = context.applicationContext private val credentialsStore = BiometricCredentialsStore(appContext) - actual suspend fun canAuthenticate(): Boolean = BiometricManager + override suspend fun canAuthenticate(): Boolean = BiometricManager .from(appContext) .canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS - actual suspend fun hasStoredPassword(): Boolean = credentialsStore.hasCredentials() + override suspend fun hasStoredPassword(): Boolean = credentialsStore.hasCredentials() - actual suspend fun encryptAndStorePassword( + override suspend fun encryptAndStorePassword( password: CharSequence, title: String, subtitle: String, @@ -64,7 +64,7 @@ actual class BiometricInteractor(context: Context) { } } - actual suspend fun decryptStoredPassword( + override suspend fun decryptStoredPassword( title: String, subtitle: String, negativeButton: String, @@ -114,7 +114,7 @@ actual class BiometricInteractor(context: Context) { } } - actual suspend fun clearStoredPassword() { + override suspend fun clearStoredPassword() { credentialsStore.clear() runCatching { KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }.deleteEntry(KEY_ALIAS) 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 index a4b9ec15f..0699c0e5d 100644 --- 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 @@ -1,6 +1,8 @@ package com.softartdev.notedelight.interactor -expect class BiometricInteractor { +import kotlinx.coroutines.channels.Channel + +interface BiometricInteractor { suspend fun canAuthenticate(): Boolean @@ -22,4 +24,8 @@ expect class BiometricInteractor { ): DecryptedPasswordResult suspend fun clearStoredPassword() + + companion object { + val disableDialogChannel: Channel by lazy { Channel() } + } } diff --git a/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt b/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/IosBiometricInteractor.kt similarity index 96% rename from feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt rename to feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/IosBiometricInteractor.kt index e5a599345..fb3103298 100644 --- a/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt +++ b/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/IosBiometricInteractor.kt @@ -59,12 +59,12 @@ import platform.Security.kSecValueData import platform.darwin.OSStatus import kotlin.coroutines.resume -actual class BiometricInteractor { +class IosBiometricInteractor : BiometricInteractor { - actual suspend fun canAuthenticate(): Boolean = LAContext() + override suspend fun canAuthenticate(): Boolean = LAContext() .canEvaluatePolicy(LAPolicyDeviceOwnerAuthenticationWithBiometrics, null) - actual suspend fun hasStoredPassword(): Boolean = memScoped { + override suspend fun hasStoredPassword(): Boolean = memScoped { val service: CFTypeRef? = CFBridgingRetain(SERVICE) val account: CFTypeRef? = CFBridgingRetain(ACCOUNT) val query: CFMutableDictionaryRef? = newMutableDict() @@ -82,7 +82,7 @@ actual class BiometricInteractor { } } - actual suspend fun encryptAndStorePassword( + override suspend fun encryptAndStorePassword( password: CharSequence, title: String, subtitle: String, @@ -132,7 +132,7 @@ actual class BiometricInteractor { } } - actual suspend fun decryptStoredPassword( + override suspend fun decryptStoredPassword( title: String, subtitle: String, negativeButton: String, @@ -191,7 +191,7 @@ actual class BiometricInteractor { } } - actual suspend fun clearStoredPassword(): Unit = memScoped { + override suspend fun clearStoredPassword(): Unit = memScoped { val service: CFTypeRef? = CFBridgingRetain(SERVICE) val account: CFTypeRef? = CFBridgingRetain(ACCOUNT) val query: CFMutableDictionaryRef? = newMutableDict() diff --git a/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt b/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/JvmBiometricInteractor.kt similarity index 59% rename from feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt rename to feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/JvmBiometricInteractor.kt index 4fc407653..c5887ce04 100644 --- a/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt +++ b/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/JvmBiometricInteractor.kt @@ -1,11 +1,12 @@ package com.softartdev.notedelight.interactor -actual class BiometricInteractor { - actual suspend fun canAuthenticate(): Boolean = false +class JvmBiometricInteractor : BiometricInteractor { - actual suspend fun hasStoredPassword(): Boolean = false + override suspend fun canAuthenticate(): Boolean = false - actual suspend fun encryptAndStorePassword( + override suspend fun hasStoredPassword(): Boolean = false + + override suspend fun encryptAndStorePassword( password: CharSequence, title: String, subtitle: String, @@ -13,12 +14,12 @@ actual class BiometricInteractor { biometricPlatformWrapper: BiometricPlatformWrapper, ): BiometricResult = BiometricResult.Unavailable - actual suspend fun decryptStoredPassword( + override suspend fun decryptStoredPassword( title: String, subtitle: String, negativeButton: String, biometricPlatformWrapper: BiometricPlatformWrapper, ): DecryptedPasswordResult = DecryptedPasswordResult.Unavailable - actual suspend fun clearStoredPassword() = Unit + override suspend fun clearStoredPassword() = Unit } diff --git a/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt b/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/WebBiometricInteractor.kt similarity index 59% rename from feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt rename to feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/WebBiometricInteractor.kt index 4fc407653..73ff49076 100644 --- a/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt +++ b/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/WebBiometricInteractor.kt @@ -1,11 +1,12 @@ package com.softartdev.notedelight.interactor -actual class BiometricInteractor { - actual suspend fun canAuthenticate(): Boolean = false +class WebBiometricInteractor : BiometricInteractor { - actual suspend fun hasStoredPassword(): Boolean = false + override suspend fun canAuthenticate(): Boolean = false - actual suspend fun encryptAndStorePassword( + override suspend fun hasStoredPassword(): Boolean = false + + override suspend fun encryptAndStorePassword( password: CharSequence, title: String, subtitle: String, @@ -13,12 +14,12 @@ actual class BiometricInteractor { biometricPlatformWrapper: BiometricPlatformWrapper, ): BiometricResult = BiometricResult.Unavailable - actual suspend fun decryptStoredPassword( + override suspend fun decryptStoredPassword( title: String, subtitle: String, negativeButton: String, biometricPlatformWrapper: BiometricPlatformWrapper, ): DecryptedPasswordResult = DecryptedPasswordResult.Unavailable - actual suspend fun clearStoredPassword() = Unit + override suspend fun clearStoredPassword() = Unit } 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() + } +}