Skip to content

Commit 2feb74c

Browse files
committed
settings: add biometric preference flow
1 parent d3c90f1 commit 2feb74c

16 files changed

Lines changed: 305 additions & 2 deletions

File tree

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ 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.BiometricCapability
12+
import com.softartdev.notedelight.interactor.BiometricInteractor
1113
import com.softartdev.notedelight.interactor.LocaleInteractor
1214
import com.softartdev.notedelight.interactor.SnackbarInteractor
1315
import com.softartdev.notedelight.model.SettingsCategory
@@ -30,6 +32,7 @@ import com.softartdev.notedelight.usecase.note.CreateNoteUseCase
3032
import com.softartdev.notedelight.usecase.note.DeleteNoteUseCase
3133
import com.softartdev.notedelight.usecase.note.SaveNoteUseCase
3234
import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase
35+
import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase
3336
import com.softartdev.notedelight.usecase.settings.AppVersionUseCase
3437
import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase
3538
import com.softartdev.notedelight.usecase.settings.ImportDatabaseUseCase
@@ -67,6 +70,7 @@ class AdaptiveInteractorTest {
6770
private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java)
6871
private val mockLocaleInteractor = Mockito.mock(LocaleInteractor::class.java)
6972
private val mockAppVersionUseCase = Mockito.mock(AppVersionUseCase::class.java)
73+
private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java)
7074
private val checkSqlCipherVersionUseCase = CheckSqlCipherVersionUseCase(mockSafeRepo)
7175
private val revealFileListUseCase = RevealFileListUseCase()
7276
private val adaptiveInteractor = AdaptiveInteractor()
@@ -110,13 +114,15 @@ class AdaptiveInteractorTest {
110114
settingsViewModel = SettingsViewModel(
111115
safeRepo = mockSafeRepo,
112116
checkSqlCipherVersionUseCase = checkSqlCipherVersionUseCase,
117+
checkPasswordUseCase = CheckPasswordUseCase(mockSafeRepo),
113118
exportDatabaseUseCase = ExportDatabaseUseCase(mockSafeRepo),
114119
importDatabaseUseCase = ImportDatabaseUseCase(mockSafeRepo),
115120
appVersionUseCase = mockAppVersionUseCase,
116121
snackbarInteractor = mockSnackbarInteractor,
117122
router = mockRouter,
118123
revealFileListUseCase = revealFileListUseCase,
119124
localeInteractor = mockLocaleInteractor,
125+
biometricInteractor = mockBiometricInteractor,
120126
adaptiveInteractor = adaptiveInteractor,
121127
coroutineDispatchers = coroutineDispatchers,
122128
)
@@ -125,11 +131,12 @@ class AdaptiveInteractorTest {
125131
Mockito.`when`(mockNoteDAO.count()).thenReturn(0)
126132
Mockito.`when`(mockCreateNoteUseCase.invoke()).thenReturn(id)
127133
Mockito.`when`(mockNoteDAO.load(id)).thenReturn(note)
134+
Mockito.`when`(mockBiometricInteractor.capability()).thenReturn(BiometricCapability(false, false))
128135
}
129136

130137
@After
131138
fun tearDown() = runTest {
132-
Mockito.reset(mockSafeRepo, mockRouter, mockNoteDAO, mockCreateNoteUseCase, mockDeleteNoteUseCase, mockSnackbarInteractor, mockLocaleInteractor, mockAppVersionUseCase)
139+
Mockito.reset(mockSafeRepo, mockRouter, mockNoteDAO, mockCreateNoteUseCase, mockDeleteNoteUseCase, mockSnackbarInteractor, mockLocaleInteractor, mockAppVersionUseCase, mockBiometricInteractor)
133140
Logger.setLogWriters()
134141
}
135142

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ 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
8+
import com.softartdev.notedelight.interactor.BiometricCapability
79
import com.softartdev.notedelight.interactor.LocaleInteractor
810
import com.softartdev.notedelight.interactor.SnackbarInteractor
911
import com.softartdev.notedelight.interactor.SnackbarMessage
@@ -16,6 +18,7 @@ import com.softartdev.notedelight.navigation.Router
1618
import com.softartdev.notedelight.presentation.MainDispatcherRule
1719
import com.softartdev.notedelight.repository.SafeRepo
1820
import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase
21+
import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase
1922
import com.softartdev.notedelight.usecase.settings.AppVersionUseCase
2023
import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase
2124
import com.softartdev.notedelight.usecase.settings.ImportDatabaseUseCase
@@ -45,25 +48,28 @@ class SettingsViewModelTest {
4548
private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java)
4649
private val mockLocaleInteractor = Mockito.mock(LocaleInteractor::class.java)
4750
private val mockAppVersionUseCase = Mockito.mock(AppVersionUseCase::class.java)
51+
private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java)
4852
private val adaptiveInteractor = AdaptiveInteractor()
4953
private val coroutineDispatchers = CoroutineDispatchersStub(mainDispatcherRule.testDispatcher.scheduler)
5054
private val settingsViewModel = SettingsViewModel(
5155
safeRepo = mockSafeRepo,
5256
checkSqlCipherVersionUseCase = checkSqlCipherVersionUseCase,
57+
checkPasswordUseCase = CheckPasswordUseCase(mockSafeRepo),
5358
exportDatabaseUseCase = ExportDatabaseUseCase(mockSafeRepo),
5459
importDatabaseUseCase = ImportDatabaseUseCase(mockSafeRepo),
5560
appVersionUseCase = mockAppVersionUseCase,
5661
snackbarInteractor = mockSnackbarInteractor,
5762
router = mockRouter,
5863
revealFileListUseCase = RevealFileListUseCase(),
5964
localeInteractor = mockLocaleInteractor,
65+
biometricInteractor = mockBiometricInteractor,
6066
adaptiveInteractor = adaptiveInteractor,
6167
coroutineDispatchers = coroutineDispatchers,
6268
)
6369

6470
@After
6571
fun tearDown() = runTest {
66-
Mockito.reset(mockSafeRepo, mockSnackbarInteractor, mockRouter, mockAppVersionUseCase)
72+
Mockito.reset(mockSafeRepo, mockSnackbarInteractor, mockRouter, mockAppVersionUseCase, mockBiometricInteractor)
6773
}
6874

6975
@Test
@@ -83,6 +89,7 @@ class SettingsViewModelTest {
8389
fun refreshUpdatesSwitches() = runTest {
8490
Mockito.`when`(mockSafeRepo.databaseState).thenReturn(ENCRYPTED)
8591
Mockito.`when`(mockLocaleInteractor.languageEnum).thenReturn(LanguageEnum.ENGLISH)
92+
Mockito.`when`(mockBiometricInteractor.capability()).thenReturn(BiometricCapability(false, false))
8693
settingsViewModel.stateFlow.test {
8794
assertFalse(awaitItem().loading)
8895
settingsViewModel.onAction(SettingsAction.Refresh)
@@ -100,6 +107,7 @@ class SettingsViewModelTest {
100107
val platformSQLiteState = if (encryption) ENCRYPTED else UNENCRYPTED
101108
Mockito.`when`(mockSafeRepo.databaseState).thenReturn(platformSQLiteState)
102109
Mockito.`when`(mockLocaleInteractor.languageEnum).thenReturn(LanguageEnum.ENGLISH)
110+
Mockito.`when`(mockBiometricInteractor.capability()).thenReturn(BiometricCapability(false, false))
103111
settingsViewModel.stateFlow.test {
104112
assertFalse(awaitItem().loading)
105113
settingsViewModel.updateSwitches()
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.softartdev.notedelight.interactor
2+
3+
import android.app.KeyguardManager
4+
import android.content.Context
5+
import android.hardware.fingerprint.FingerprintManager
6+
import android.os.Build
7+
8+
actual class BiometricInteractor(private val context: Context) {
9+
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
10+
11+
actual var biometricEnabled: Boolean
12+
get() = prefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
13+
set(value) {
14+
prefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, value).apply()
15+
}
16+
17+
actual var biometricConfirmed: Boolean
18+
get() = prefs.getBoolean(KEY_BIOMETRIC_CONFIRMED, false)
19+
set(value) {
20+
prefs.edit().putBoolean(KEY_BIOMETRIC_CONFIRMED, value).apply()
21+
}
22+
23+
actual fun capability(): BiometricCapability {
24+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
25+
return BiometricCapability(available = false, enrolled = false)
26+
}
27+
val fingerprintManager = context.getSystemService(Context.FINGERPRINT_SERVICE) as? FingerprintManager
28+
?: return BiometricCapability(available = false, enrolled = false)
29+
val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
30+
val available = fingerprintManager.isHardwareDetected && (keyguardManager?.isKeyguardSecure == true)
31+
val enrolled = available && fingerprintManager.hasEnrolledFingerprints()
32+
return BiometricCapability(available = available, enrolled = enrolled)
33+
}
34+
35+
private companion object {
36+
private const val PREFS_NAME = "notedelight_settings"
37+
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
38+
private const val KEY_BIOMETRIC_CONFIRMED = "biometric_confirmed"
39+
}
40+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.softartdev.notedelight.interactor
2+
3+
data class BiometricCapability(
4+
val available: Boolean,
5+
val enrolled: Boolean,
6+
)
7+
8+
expect class BiometricInteractor {
9+
var biometricEnabled: Boolean
10+
var biometricConfirmed: Boolean
11+
fun capability(): BiometricCapability
12+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import com.softartdev.notedelight.model.SettingsCategory
66
data class SettingsResult(
77
val loading: Boolean = false,
88
val encryption: Boolean = false,
9+
val biometricEnabled: Boolean = false,
10+
val biometricAvailable: Boolean = false,
11+
val biometricEnrolled: Boolean = false,
12+
val biometricNeedsPasswordConfirmation: Boolean = true,
913
val fileListVisible: Boolean = false,
1014
val language: LanguageEnum = LanguageEnum.ENGLISH,
1115
val appVersion: String? = null,
@@ -23,6 +27,7 @@ sealed interface SettingsAction {
2327
data object ChangeTheme : SettingsAction
2428
data object ChangeLanguage : SettingsAction
2529
data class ChangeEncryption(val checked: Boolean) : SettingsAction
30+
data class ChangeBiometric(val checked: Boolean, val password: String? = null) : SettingsAction
2631
data object ChangePassword : SettingsAction
2732
data object ShowCipherVersion : SettingsAction
2833
data object ShowDatabasePath : SettingsAction

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
44
import androidx.lifecycle.viewModelScope
55
import co.touchlab.kermit.Logger
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
@@ -13,6 +14,7 @@ import com.softartdev.notedelight.navigation.AppNavGraph
1314
import com.softartdev.notedelight.navigation.Router
1415
import com.softartdev.notedelight.repository.SafeRepo
1516
import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase
17+
import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase
1618
import com.softartdev.notedelight.usecase.settings.AppVersionUseCase
1719
import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase
1820
import com.softartdev.notedelight.usecase.settings.ImportDatabaseUseCase
@@ -29,13 +31,15 @@ import kotlinx.coroutines.withContext
2931
class SettingsViewModel(
3032
private val safeRepo: SafeRepo,
3133
private val checkSqlCipherVersionUseCase: CheckSqlCipherVersionUseCase,
34+
private val checkPasswordUseCase: CheckPasswordUseCase,
3235
private val exportDatabaseUseCase: ExportDatabaseUseCase,
3336
private val importDatabaseUseCase: ImportDatabaseUseCase,
3437
private val appVersionUseCase: AppVersionUseCase,
3538
private val snackbarInteractor: SnackbarInteractor,
3639
private val router: Router,
3740
private val revealFileListUseCase: RevealFileListUseCase,
3841
private val localeInteractor: LocaleInteractor,
42+
private val biometricInteractor: BiometricInteractor,
3943
private val adaptiveInteractor: AdaptiveInteractor,
4044
private val coroutineDispatchers: CoroutineDispatchers,
4145
) : ViewModel() {
@@ -62,6 +66,7 @@ class SettingsViewModel(
6266
is SettingsAction.ChangeTheme -> changeTheme()
6367
is SettingsAction.ChangeLanguage -> changeLanguage()
6468
is SettingsAction.ChangeEncryption -> changeEncryption(action.checked)
69+
is SettingsAction.ChangeBiometric -> changeBiometric(action.checked, action.password)
6570
is SettingsAction.ChangePassword -> changePassword()
6671
is SettingsAction.ShowCipherVersion -> showCipherVersion()
6772
is SettingsAction.ShowDatabasePath -> showDatabasePath()
@@ -76,8 +81,20 @@ class SettingsViewModel(
7681
mutableStateFlow.update(SettingsResult::showLoading)
7782
try {
7883
mutableStateFlow.update { result ->
84+
val biometricCapability = biometricInteractor.capability()
85+
val biometricEnabled = biometricInteractor.biometricEnabled
86+
if (biometricEnabled && (!biometricCapability.available || !biometricCapability.enrolled)) {
87+
biometricInteractor.biometricEnabled = false
88+
snackbarInteractor.showMessage(
89+
SnackbarMessage.Simple("Biometrics were disabled because device capability changed.")
90+
)
91+
}
7992
result.copy(
8093
encryption = dbIsEncrypted,
94+
biometricEnabled = biometricInteractor.biometricEnabled,
95+
biometricAvailable = biometricCapability.available,
96+
biometricEnrolled = biometricCapability.enrolled,
97+
biometricNeedsPasswordConfirmation = !biometricInteractor.biometricConfirmed,
8198
language = localeInteractor.languageEnum,
8299
appVersion = appVersionUseCase.invoke()
83100
)
@@ -150,6 +167,45 @@ class SettingsViewModel(
150167
}
151168
}
152169

170+
private fun changeBiometric(checked: Boolean, password: String?) = viewModelScope.launch {
171+
CountingIdlingRes.increment()
172+
mutableStateFlow.update(SettingsResult::showLoading)
173+
try {
174+
val capability = biometricInteractor.capability()
175+
when {
176+
!checked -> biometricInteractor.biometricEnabled = false
177+
!capability.available || !capability.enrolled -> {
178+
biometricInteractor.biometricEnabled = false
179+
snackbarInteractor.showMessage(SnackbarMessage.Simple("Biometrics are not available on this device."))
180+
}
181+
!dbIsEncrypted -> {
182+
biometricInteractor.biometricEnabled = false
183+
snackbarInteractor.showMessage(
184+
SnackbarMessage.Simple("Enable password protection before turning on biometrics.")
185+
)
186+
}
187+
!biometricInteractor.biometricConfirmed -> {
188+
if (password.isNullOrEmpty()) {
189+
snackbarInteractor.showMessage(SnackbarMessage.Simple("Confirm your password to enable biometrics."))
190+
} else if (checkPasswordUseCase(password)) {
191+
biometricInteractor.biometricConfirmed = true
192+
biometricInteractor.biometricEnabled = true
193+
} else {
194+
biometricInteractor.biometricEnabled = false
195+
snackbarInteractor.showMessage(SnackbarMessage.Simple("Incorrect password. Biometrics remain off."))
196+
}
197+
}
198+
else -> biometricInteractor.biometricEnabled = true
199+
}
200+
updateSwitches()
201+
} catch (e: Throwable) {
202+
handleError(e) { "error changing biometrics" }
203+
} finally {
204+
mutableStateFlow.update(SettingsResult::hideLoading)
205+
CountingIdlingRes.decrement()
206+
}
207+
}
208+
153209
private fun showCipherVersion() = viewModelScope.launch {
154210
CountingIdlingRes.increment()
155211
mutableStateFlow.update(SettingsResult::showLoading)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.softartdev.notedelight.interactor
2+
3+
import platform.Foundation.NSUserDefaults
4+
5+
actual class BiometricInteractor {
6+
actual var biometricEnabled: Boolean
7+
get() = NSUserDefaults.standardUserDefaults.boolForKey(KEY_BIOMETRIC_ENABLED)
8+
set(value) {
9+
NSUserDefaults.standardUserDefaults.setBool(value, KEY_BIOMETRIC_ENABLED)
10+
}
11+
12+
actual var biometricConfirmed: Boolean
13+
get() = NSUserDefaults.standardUserDefaults.boolForKey(KEY_BIOMETRIC_CONFIRMED)
14+
set(value) {
15+
NSUserDefaults.standardUserDefaults.setBool(value, KEY_BIOMETRIC_CONFIRMED)
16+
}
17+
18+
actual fun capability(): BiometricCapability = BiometricCapability(
19+
available = false,
20+
enrolled = false,
21+
)
22+
23+
private companion object {
24+
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
25+
private const val KEY_BIOMETRIC_CONFIRMED = "biometric_confirmed"
26+
}
27+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.softartdev.notedelight.interactor
2+
3+
import java.util.prefs.Preferences
4+
5+
actual class BiometricInteractor {
6+
private val preferences = Preferences.userRoot().node(PREFS_NODE)
7+
8+
actual var biometricEnabled: Boolean
9+
get() = preferences.getBoolean(KEY_BIOMETRIC_ENABLED, false)
10+
set(value) {
11+
preferences.putBoolean(KEY_BIOMETRIC_ENABLED, value)
12+
}
13+
14+
actual var biometricConfirmed: Boolean
15+
get() = preferences.getBoolean(KEY_BIOMETRIC_CONFIRMED, false)
16+
set(value) {
17+
preferences.putBoolean(KEY_BIOMETRIC_CONFIRMED, value)
18+
}
19+
20+
actual fun capability(): BiometricCapability = BiometricCapability(
21+
available = false,
22+
enrolled = false,
23+
)
24+
25+
private companion object {
26+
private const val PREFS_NODE = "com.softartdev.notedelight.settings"
27+
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
28+
private const val KEY_BIOMETRIC_CONFIRMED = "biometric_confirmed"
29+
}
30+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.softartdev.notedelight.interactor
2+
3+
import kotlinx.browser.window
4+
5+
actual class BiometricInteractor {
6+
actual var biometricEnabled: Boolean
7+
get() = localStorageItem(KEY_BIOMETRIC_ENABLED) == TRUE_VALUE
8+
set(value) {
9+
setLocalStorageItem(KEY_BIOMETRIC_ENABLED, value.toString())
10+
}
11+
12+
actual var biometricConfirmed: Boolean
13+
get() = localStorageItem(KEY_BIOMETRIC_CONFIRMED) == TRUE_VALUE
14+
set(value) {
15+
setLocalStorageItem(KEY_BIOMETRIC_CONFIRMED, value.toString())
16+
}
17+
18+
actual fun capability(): BiometricCapability = BiometricCapability(
19+
available = false,
20+
enrolled = false,
21+
)
22+
23+
private companion object {
24+
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
25+
private const val KEY_BIOMETRIC_CONFIRMED = "biometric_confirmed"
26+
private const val TRUE_VALUE = "true"
27+
}
28+
}
29+
30+
private fun localStorageItem(key: String): String? = window.localStorage.getItem(key)
31+
32+
private fun setLocalStorageItem(key: String, value: String) = window.localStorage.setItem(key, value)

0 commit comments

Comments
 (0)