Skip to content

Commit facccbf

Browse files
committed
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
1 parent d3c90f1 commit facccbf

43 files changed

Lines changed: 1097 additions & 18 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/android/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
33

4+
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
5+
46
<application
57
android:name=".MainApplication"
68
android:icon="@mipmap/ic_launcher"
@@ -9,6 +11,7 @@
911
android:theme="@style/Theme.NoteDelight">
1012
<activity
1113
android:name=".MainActivity"
14+
android:configChanges="orientation|screenSize|keyboardHidden"
1215
android:exported="true"
1316
android:windowSoftInputMode="adjustResize">
1417
<intent-filter>

app/android/src/main/java/com/softartdev/notedelight/MainActivity.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,23 @@ package com.softartdev.notedelight
33
import android.os.Bundle
44
import androidx.activity.compose.setContent
55
import androidx.appcompat.app.AppCompatActivity
6+
import com.softartdev.notedelight.interactor.BiometricActivityHolder
7+
import org.koin.android.ext.android.inject
68

79
class MainActivity : AppCompatActivity() {
810

11+
private val biometricActivityHolder: BiometricActivityHolder by inject()
12+
913
override fun onCreate(savedInstanceState: Bundle?) {
1014
super.onCreate(savedInstanceState)
15+
biometricActivityHolder.attach(this)
1116
setContent {
1217
App()
1318
}
1419
}
20+
21+
override fun onDestroy() {
22+
biometricActivityHolder.detach()
23+
super.onDestroy()
24+
}
1525
}

app/iosApp/iosApp/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
<dict>
55
<key>ITSAppUsesNonExemptEncryption</key>
66
<false/>
7+
<key>NSFaceIDUsageDescription</key>
8+
<string>Used to unlock your encrypted notes.</string>
79
<key>CFBundleDevelopmentRegion</key>
810
<string>$(DEVELOPMENT_LANGUAGE)</string>
911
<key>CFBundleExecutable</key>

core/presentation/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ kotlin {
4747
}
4848
androidMain.dependencies {
4949
implementation(libs.androidx.appcompat)
50+
implementation(libs.androidx.biometric)
5051
}
5152
val androidHostTest by getting {
5253
dependencies {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.softartdev.notedelight.CoroutineDispatchersStub
88
import com.softartdev.notedelight.PrintLogWriter
99
import com.softartdev.notedelight.db.NoteDAO
1010
import com.softartdev.notedelight.interactor.AdaptiveInteractor
11+
import com.softartdev.notedelight.interactor.BiometricInteractor
1112
import com.softartdev.notedelight.interactor.LocaleInteractor
1213
import com.softartdev.notedelight.interactor.SnackbarInteractor
1314
import com.softartdev.notedelight.model.SettingsCategory
@@ -66,6 +67,7 @@ class AdaptiveInteractorTest {
6667
private val mockDeleteNoteUseCase = Mockito.mock(DeleteNoteUseCase::class.java)
6768
private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java)
6869
private val mockLocaleInteractor = Mockito.mock(LocaleInteractor::class.java)
70+
private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java)
6971
private val mockAppVersionUseCase = Mockito.mock(AppVersionUseCase::class.java)
7072
private val checkSqlCipherVersionUseCase = CheckSqlCipherVersionUseCase(mockSafeRepo)
7173
private val revealFileListUseCase = RevealFileListUseCase()
@@ -118,6 +120,7 @@ class AdaptiveInteractorTest {
118120
revealFileListUseCase = revealFileListUseCase,
119121
localeInteractor = mockLocaleInteractor,
120122
adaptiveInteractor = adaptiveInteractor,
123+
biometricInteractor = mockBiometricInteractor,
121124
coroutineDispatchers = coroutineDispatchers,
122125
)
123126
Mockito.`when`(mockNoteDAO.pagingDataFlow).thenReturn(flowOf(PagingData.empty()))

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
44
import app.cash.turbine.test
55
import com.softartdev.notedelight.CoroutineDispatchersStub
66
import com.softartdev.notedelight.interactor.AdaptiveInteractor
7+
import com.softartdev.notedelight.interactor.BiometricInteractor
78
import com.softartdev.notedelight.interactor.LocaleInteractor
89
import com.softartdev.notedelight.interactor.SnackbarInteractor
910
import com.softartdev.notedelight.interactor.SnackbarMessage
@@ -44,6 +45,7 @@ class SettingsViewModelTest {
4445
private val mockRouter = Mockito.mock(Router::class.java)
4546
private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java)
4647
private val mockLocaleInteractor = Mockito.mock(LocaleInteractor::class.java)
48+
private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java)
4749
private val mockAppVersionUseCase = Mockito.mock(AppVersionUseCase::class.java)
4850
private val adaptiveInteractor = AdaptiveInteractor()
4951
private val coroutineDispatchers = CoroutineDispatchersStub(mainDispatcherRule.testDispatcher.scheduler)
@@ -58,12 +60,13 @@ class SettingsViewModelTest {
5860
revealFileListUseCase = RevealFileListUseCase(),
5961
localeInteractor = mockLocaleInteractor,
6062
adaptiveInteractor = adaptiveInteractor,
63+
biometricInteractor = mockBiometricInteractor,
6164
coroutineDispatchers = coroutineDispatchers,
6265
)
6366

6467
@After
6568
fun tearDown() = runTest {
66-
Mockito.reset(mockSafeRepo, mockSnackbarInteractor, mockRouter, mockAppVersionUseCase)
69+
Mockito.reset(mockSafeRepo, mockSnackbarInteractor, mockRouter, mockAppVersionUseCase, mockBiometricInteractor)
6770
}
6871

6972
@Test

core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/security/change/ChangeViewModelTest.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import app.cash.turbine.test
55
import co.touchlab.kermit.Logger
66
import com.softartdev.notedelight.CoroutineDispatchersStub
77
import com.softartdev.notedelight.PrintLogWriter
8+
import com.softartdev.notedelight.interactor.BiometricInteractor
89
import com.softartdev.notedelight.interactor.SnackbarInteractor
910
import com.softartdev.notedelight.navigation.Router
1011
import com.softartdev.notedelight.presentation.MainDispatcherRule
@@ -34,6 +35,7 @@ class ChangeViewModelTest {
3435

3536
private val mockCheckPasswordUseCase = Mockito.mock(CheckPasswordUseCase::class.java)
3637
private val mockChangePasswordUseCase = Mockito.mock(ChangePasswordUseCase::class.java)
38+
private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java)
3739
private val mockRouter = Mockito.mock(Router::class.java)
3840
private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java)
3941
private val coroutineDispatchers = CoroutineDispatchersStub(
@@ -42,6 +44,7 @@ class ChangeViewModelTest {
4244
private val viewModel = ChangeViewModel(
4345
checkPasswordUseCase = mockCheckPasswordUseCase,
4446
changePasswordUseCase = mockChangePasswordUseCase,
47+
biometricInteractor = mockBiometricInteractor,
4548
snackbarInteractor = mockSnackbarInteractor,
4649
router = mockRouter,
4750
coroutineDispatchers = coroutineDispatchers
@@ -53,7 +56,7 @@ class ChangeViewModelTest {
5356
@After
5457
fun tearDown() {
5558
Logger.setLogWriters()
56-
Mockito.reset(mockCheckPasswordUseCase, mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter)
59+
Mockito.reset(mockCheckPasswordUseCase, mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter, mockBiometricInteractor)
5760
}
5861

5962
@Test

core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/security/confirm/ConfirmViewModelTest.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import app.cash.turbine.test
55
import co.touchlab.kermit.Logger
66
import com.softartdev.notedelight.CoroutineDispatchersStub
77
import com.softartdev.notedelight.PrintLogWriter
8+
import com.softartdev.notedelight.interactor.BiometricInteractor
89
import com.softartdev.notedelight.interactor.SnackbarInteractor
910
import com.softartdev.notedelight.navigation.Router
1011
import com.softartdev.notedelight.presentation.MainDispatcherRule
@@ -32,13 +33,15 @@ class ConfirmViewModelTest {
3233
val mainDispatcherRule = MainDispatcherRule()
3334

3435
private val mockChangePasswordUseCase = Mockito.mock(ChangePasswordUseCase::class.java)
36+
private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java)
3537
private val mockRouter = Mockito.mock(Router::class.java)
3638
private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java)
3739
private val coroutineDispatchers = CoroutineDispatchersStub(
3840
scheduler = mainDispatcherRule.testDispatcher.scheduler
3941
)
4042
private val viewModel = ConfirmViewModel(
4143
changePasswordUseCase = mockChangePasswordUseCase,
44+
biometricInteractor = mockBiometricInteractor,
4245
snackbarInteractor = mockSnackbarInteractor,
4346
router = mockRouter,
4447
coroutineDispatchers = coroutineDispatchers
@@ -50,7 +53,7 @@ class ConfirmViewModelTest {
5053
@After
5154
fun tearDown() {
5255
Logger.setLogWriters()
53-
Mockito.reset(mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter)
56+
Mockito.reset(mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter, mockBiometricInteractor)
5457
}
5558

5659
@Test

core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/security/enter/EnterViewModelTest.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import app.cash.turbine.test
55
import co.touchlab.kermit.Logger
66
import com.softartdev.notedelight.CoroutineDispatchersStub
77
import com.softartdev.notedelight.PrintLogWriter
8+
import com.softartdev.notedelight.interactor.BiometricInteractor
89
import com.softartdev.notedelight.interactor.SnackbarInteractor
910
import com.softartdev.notedelight.navigation.Router
1011
import com.softartdev.notedelight.presentation.MainDispatcherRule
@@ -34,6 +35,7 @@ class EnterViewModelTest {
3435

3536
private val mockCheckPasswordUseCase = Mockito.mock(CheckPasswordUseCase::class.java)
3637
private val mockChangePasswordUseCase = Mockito.mock(ChangePasswordUseCase::class.java)
38+
private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java)
3739
private val mockRouter = Mockito.mock(Router::class.java)
3840
private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java)
3941
private val coroutineDispatchers = CoroutineDispatchersStub(
@@ -42,6 +44,7 @@ class EnterViewModelTest {
4244
private val viewModel = EnterViewModel(
4345
checkPasswordUseCase = mockCheckPasswordUseCase,
4446
changePasswordUseCase = mockChangePasswordUseCase,
47+
biometricInteractor = mockBiometricInteractor,
4548
snackbarInteractor = mockSnackbarInteractor,
4649
router = mockRouter,
4750
coroutineDispatchers = coroutineDispatchers
@@ -53,7 +56,7 @@ class EnterViewModelTest {
5356
@After
5457
fun tearDown() {
5558
Logger.setLogWriters()
56-
Mockito.reset(mockCheckPasswordUseCase, mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter)
59+
Mockito.reset(mockCheckPasswordUseCase, mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter, mockBiometricInteractor)
5760
}
5861

5962
@Test

core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModelTest.kt

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@ import androidx.compose.ui.autofill.AutofillManager
55
import app.cash.turbine.test
66
import com.softartdev.notedelight.StubEditable
77
import com.softartdev.notedelight.anyObject
8+
import com.softartdev.notedelight.interactor.BiometricInteractor
9+
import com.softartdev.notedelight.interactor.BiometricResult
10+
import com.softartdev.notedelight.interactor.DecryptedPasswordResult
811
import com.softartdev.notedelight.navigation.AppNavGraph
912
import com.softartdev.notedelight.navigation.Router
1013
import com.softartdev.notedelight.presentation.MainDispatcherRule
1114
import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase
1215
import kotlinx.coroutines.ExperimentalCoroutinesApi
1316
import kotlinx.coroutines.test.runTest
1417
import org.junit.Assert.assertEquals
18+
import org.junit.Assert.assertFalse
19+
import org.junit.Assert.assertTrue
1520
import org.junit.Before
1621
import org.junit.Rule
1722
import org.junit.Test
@@ -27,14 +32,17 @@ class SignInViewModelTest {
2732
val mainDispatcherRule = MainDispatcherRule()
2833

2934
private val mockCheckPasswordUseCase = Mockito.mock(CheckPasswordUseCase::class.java)
35+
private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java)
3036
private val mockRouter = Mockito.mock(Router::class.java)
3137
private val mockAutofillManager = Mockito.mock(AutofillManager::class.java)
32-
38+
3339
private lateinit var signInViewModel: SignInViewModel
3440

3541
@Before
3642
fun setUp() {
37-
signInViewModel = SignInViewModel(mockCheckPasswordUseCase, mockRouter)
43+
signInViewModel = SignInViewModel(
44+
mockCheckPasswordUseCase, mockBiometricInteractor, mockRouter
45+
)
3846
signInViewModel.autofillManager = mockAutofillManager
3947
}
4048

@@ -114,4 +122,48 @@ class SignInViewModelTest {
114122
cancelAndIgnoreRemainingEvents()
115123
}
116124
}
125+
126+
@Test
127+
fun refreshBiometricVisibleWhenAvailable() = runTest {
128+
Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(true)
129+
Mockito.`when`(mockBiometricInteractor.canAuthenticate()).thenReturn(true)
130+
signInViewModel.biometricVisibleFlow.test {
131+
assertFalse(awaitItem())
132+
signInViewModel.onAction(SignInAction.RefreshBiometric)
133+
assertTrue(awaitItem())
134+
cancelAndIgnoreRemainingEvents()
135+
}
136+
}
137+
138+
@Test
139+
fun biometricSignInSuccess() = runTest {
140+
val pass = StubEditable("pass")
141+
Mockito.`when`(
142+
mockBiometricInteractor.decryptStoredPassword(
143+
anyObject(), anyObject(), anyObject()
144+
)
145+
).thenReturn(DecryptedPasswordResult.Success(pass))
146+
Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true)
147+
signInViewModel.stateFlow.test {
148+
assertEquals(SignInResult.ShowSignInForm, awaitItem())
149+
signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c"))
150+
Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Main)
151+
cancelAndIgnoreRemainingEvents()
152+
}
153+
}
154+
155+
@Test
156+
fun biometricSignInUnavailableClearsState() = runTest {
157+
Mockito.`when`(
158+
mockBiometricInteractor.decryptStoredPassword(
159+
anyObject(), anyObject(), anyObject()
160+
)
161+
).thenReturn(DecryptedPasswordResult.Failure(BiometricResult.Unavailable))
162+
signInViewModel.biometricVisibleFlow.test {
163+
assertFalse(awaitItem())
164+
signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c"))
165+
Mockito.verify(mockBiometricInteractor).clearStoredPassword()
166+
cancelAndIgnoreRemainingEvents()
167+
}
168+
}
117169
}

0 commit comments

Comments
 (0)