Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import com.softartdev.notedelight.CoroutineDispatchersStub
import com.softartdev.notedelight.PrintLogWriter
import com.softartdev.notedelight.db.NoteDAO
import com.softartdev.notedelight.interactor.AdaptiveInteractor
import com.softartdev.notedelight.interactor.BiometricCapability
import com.softartdev.notedelight.interactor.BiometricInteractor
import com.softartdev.notedelight.interactor.LocaleInteractor
import com.softartdev.notedelight.interactor.SnackbarInteractor
import com.softartdev.notedelight.model.SettingsCategory
Expand All @@ -30,6 +32,7 @@ import com.softartdev.notedelight.usecase.note.CreateNoteUseCase
import com.softartdev.notedelight.usecase.note.DeleteNoteUseCase
import com.softartdev.notedelight.usecase.note.SaveNoteUseCase
import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase
import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase
import com.softartdev.notedelight.usecase.settings.AppVersionUseCase
import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase
import com.softartdev.notedelight.usecase.settings.ImportDatabaseUseCase
Expand Down Expand Up @@ -67,6 +70,7 @@ class AdaptiveInteractorTest {
private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java)
private val mockLocaleInteractor = Mockito.mock(LocaleInteractor::class.java)
private val mockAppVersionUseCase = Mockito.mock(AppVersionUseCase::class.java)
private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java)
private val checkSqlCipherVersionUseCase = CheckSqlCipherVersionUseCase(mockSafeRepo)
private val revealFileListUseCase = RevealFileListUseCase()
private val adaptiveInteractor = AdaptiveInteractor()
Expand Down Expand Up @@ -110,13 +114,15 @@ class AdaptiveInteractorTest {
settingsViewModel = SettingsViewModel(
safeRepo = mockSafeRepo,
checkSqlCipherVersionUseCase = checkSqlCipherVersionUseCase,
checkPasswordUseCase = CheckPasswordUseCase(mockSafeRepo),
exportDatabaseUseCase = ExportDatabaseUseCase(mockSafeRepo),
importDatabaseUseCase = ImportDatabaseUseCase(mockSafeRepo),
appVersionUseCase = mockAppVersionUseCase,
snackbarInteractor = mockSnackbarInteractor,
router = mockRouter,
revealFileListUseCase = revealFileListUseCase,
localeInteractor = mockLocaleInteractor,
biometricInteractor = mockBiometricInteractor,
adaptiveInteractor = adaptiveInteractor,
coroutineDispatchers = coroutineDispatchers,
)
Expand All @@ -125,11 +131,12 @@ class AdaptiveInteractorTest {
Mockito.`when`(mockNoteDAO.count()).thenReturn(0)
Mockito.`when`(mockCreateNoteUseCase.invoke()).thenReturn(id)
Mockito.`when`(mockNoteDAO.load(id)).thenReturn(note)
Mockito.`when`(mockBiometricInteractor.capability()).thenReturn(BiometricCapability(false, false))
}

@After
fun tearDown() = runTest {
Mockito.reset(mockSafeRepo, mockRouter, mockNoteDAO, mockCreateNoteUseCase, mockDeleteNoteUseCase, mockSnackbarInteractor, mockLocaleInteractor, mockAppVersionUseCase)
Mockito.reset(mockSafeRepo, mockRouter, mockNoteDAO, mockCreateNoteUseCase, mockDeleteNoteUseCase, mockSnackbarInteractor, mockLocaleInteractor, mockAppVersionUseCase, mockBiometricInteractor)
Logger.setLogWriters()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import app.cash.turbine.test
import com.softartdev.notedelight.CoroutineDispatchersStub
import com.softartdev.notedelight.interactor.AdaptiveInteractor
import com.softartdev.notedelight.interactor.BiometricInteractor
import com.softartdev.notedelight.interactor.BiometricCapability
import com.softartdev.notedelight.interactor.LocaleInteractor
import com.softartdev.notedelight.interactor.SnackbarInteractor
import com.softartdev.notedelight.interactor.SnackbarMessage
Expand All @@ -16,6 +18,7 @@ import com.softartdev.notedelight.navigation.Router
import com.softartdev.notedelight.presentation.MainDispatcherRule
import com.softartdev.notedelight.repository.SafeRepo
import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase
import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase
import com.softartdev.notedelight.usecase.settings.AppVersionUseCase
import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase
import com.softartdev.notedelight.usecase.settings.ImportDatabaseUseCase
Expand Down Expand Up @@ -45,25 +48,28 @@ class SettingsViewModelTest {
private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java)
private val mockLocaleInteractor = Mockito.mock(LocaleInteractor::class.java)
private val mockAppVersionUseCase = Mockito.mock(AppVersionUseCase::class.java)
private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java)
private val adaptiveInteractor = AdaptiveInteractor()
private val coroutineDispatchers = CoroutineDispatchersStub(mainDispatcherRule.testDispatcher.scheduler)
private val settingsViewModel = SettingsViewModel(
safeRepo = mockSafeRepo,
checkSqlCipherVersionUseCase = checkSqlCipherVersionUseCase,
checkPasswordUseCase = CheckPasswordUseCase(mockSafeRepo),
exportDatabaseUseCase = ExportDatabaseUseCase(mockSafeRepo),
importDatabaseUseCase = ImportDatabaseUseCase(mockSafeRepo),
appVersionUseCase = mockAppVersionUseCase,
snackbarInteractor = mockSnackbarInteractor,
router = mockRouter,
revealFileListUseCase = RevealFileListUseCase(),
localeInteractor = mockLocaleInteractor,
biometricInteractor = mockBiometricInteractor,
adaptiveInteractor = adaptiveInteractor,
coroutineDispatchers = coroutineDispatchers,
)

@After
fun tearDown() = runTest {
Mockito.reset(mockSafeRepo, mockSnackbarInteractor, mockRouter, mockAppVersionUseCase)
Mockito.reset(mockSafeRepo, mockSnackbarInteractor, mockRouter, mockAppVersionUseCase, mockBiometricInteractor)
}

@Test
Expand All @@ -83,6 +89,7 @@ class SettingsViewModelTest {
fun refreshUpdatesSwitches() = runTest {
Mockito.`when`(mockSafeRepo.databaseState).thenReturn(ENCRYPTED)
Mockito.`when`(mockLocaleInteractor.languageEnum).thenReturn(LanguageEnum.ENGLISH)
Mockito.`when`(mockBiometricInteractor.capability()).thenReturn(BiometricCapability(false, false))
settingsViewModel.stateFlow.test {
assertFalse(awaitItem().loading)
settingsViewModel.onAction(SettingsAction.Refresh)
Expand All @@ -100,6 +107,7 @@ class SettingsViewModelTest {
val platformSQLiteState = if (encryption) ENCRYPTED else UNENCRYPTED
Mockito.`when`(mockSafeRepo.databaseState).thenReturn(platformSQLiteState)
Mockito.`when`(mockLocaleInteractor.languageEnum).thenReturn(LanguageEnum.ENGLISH)
Mockito.`when`(mockBiometricInteractor.capability()).thenReturn(BiometricCapability(false, false))
settingsViewModel.stateFlow.test {
assertFalse(awaitItem().loading)
settingsViewModel.updateSwitches()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.softartdev.notedelight.interactor

import android.app.KeyguardManager
import android.content.Context
import android.hardware.fingerprint.FingerprintManager
import android.os.Build

actual class BiometricInteractor(private val context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

actual var biometricEnabled: Boolean
get() = prefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
set(value) {
prefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, value).apply()
}

actual var biometricConfirmed: Boolean
get() = prefs.getBoolean(KEY_BIOMETRIC_CONFIRMED, false)
set(value) {
prefs.edit().putBoolean(KEY_BIOMETRIC_CONFIRMED, value).apply()
}

actual fun capability(): BiometricCapability {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return BiometricCapability(available = false, enrolled = false)
}
val fingerprintManager = context.getSystemService(Context.FINGERPRINT_SERVICE) as? FingerprintManager
?: return BiometricCapability(available = false, enrolled = false)
val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
val available = fingerprintManager.isHardwareDetected && (keyguardManager?.isKeyguardSecure == true)
val enrolled = available && fingerprintManager.hasEnrolledFingerprints()
Comment on lines +30 to +31
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard fingerprint checks with required permission

capability() calls FingerprintManager.isHardwareDetected() and hasEnrolledFingerprints() without any declared biometric/fingerprint permission in the Android manifests, so this path can raise SecurityException at runtime when settings refreshes (it runs from updateSwitches() on resume). Please declare USE_BIOMETRIC/USE_FINGERPRINT in the app manifest (or use an API path that avoids this requirement) before invoking these methods.

Useful? React with 👍 / 👎.

Comment on lines +27 to +31
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use modality-agnostic biometric capability detection

This implementation treats biometrics as available only when a fingerprint sensor is present, which incorrectly disables the new “Enable biometrics” setting on devices that support non-fingerprint biometrics (for example, face/iris-only hardware). Since the feature is exposed as generic biometrics, capability should be checked via BiometricManager.canAuthenticate(...) (or equivalent) rather than fingerprint-only APIs.

Useful? React with 👍 / 👎.

return BiometricCapability(available = available, enrolled = enrolled)
}

private companion object {
private const val PREFS_NAME = "notedelight_settings"
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
private const val KEY_BIOMETRIC_CONFIRMED = "biometric_confirmed"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.softartdev.notedelight.interactor

data class BiometricCapability(
val available: Boolean,
val enrolled: Boolean,
)

expect class BiometricInteractor {
var biometricEnabled: Boolean
var biometricConfirmed: Boolean
fun capability(): BiometricCapability
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import com.softartdev.notedelight.model.SettingsCategory
data class SettingsResult(
val loading: Boolean = false,
val encryption: Boolean = false,
val biometricEnabled: Boolean = false,
val biometricAvailable: Boolean = false,
val biometricEnrolled: Boolean = false,
val biometricNeedsPasswordConfirmation: Boolean = true,
val fileListVisible: Boolean = false,
val language: LanguageEnum = LanguageEnum.ENGLISH,
val appVersion: String? = null,
Expand All @@ -23,6 +27,7 @@ sealed interface SettingsAction {
data object ChangeTheme : SettingsAction
data object ChangeLanguage : SettingsAction
data class ChangeEncryption(val checked: Boolean) : SettingsAction
data class ChangeBiometric(val checked: Boolean, val password: String? = null) : SettingsAction
data object ChangePassword : SettingsAction
data object ShowCipherVersion : SettingsAction
data object ShowDatabasePath : SettingsAction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,6 +14,7 @@ import com.softartdev.notedelight.navigation.AppNavGraph
import com.softartdev.notedelight.navigation.Router
import com.softartdev.notedelight.repository.SafeRepo
import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase
import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase
import com.softartdev.notedelight.usecase.settings.AppVersionUseCase
import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase
import com.softartdev.notedelight.usecase.settings.ImportDatabaseUseCase
Expand All @@ -29,13 +31,15 @@ import kotlinx.coroutines.withContext
class SettingsViewModel(
private val safeRepo: SafeRepo,
private val checkSqlCipherVersionUseCase: CheckSqlCipherVersionUseCase,
private val checkPasswordUseCase: CheckPasswordUseCase,
private val exportDatabaseUseCase: ExportDatabaseUseCase,
private val importDatabaseUseCase: ImportDatabaseUseCase,
private val appVersionUseCase: AppVersionUseCase,
private val snackbarInteractor: SnackbarInteractor,
private val router: Router,
private val revealFileListUseCase: RevealFileListUseCase,
private val localeInteractor: LocaleInteractor,
private val biometricInteractor: BiometricInteractor,
private val adaptiveInteractor: AdaptiveInteractor,
private val coroutineDispatchers: CoroutineDispatchers,
) : ViewModel() {
Expand All @@ -62,6 +66,7 @@ class SettingsViewModel(
is SettingsAction.ChangeTheme -> changeTheme()
is SettingsAction.ChangeLanguage -> changeLanguage()
is SettingsAction.ChangeEncryption -> changeEncryption(action.checked)
is SettingsAction.ChangeBiometric -> changeBiometric(action.checked, action.password)
is SettingsAction.ChangePassword -> changePassword()
is SettingsAction.ShowCipherVersion -> showCipherVersion()
is SettingsAction.ShowDatabasePath -> showDatabasePath()
Expand All @@ -76,8 +81,20 @@ class SettingsViewModel(
mutableStateFlow.update(SettingsResult::showLoading)
try {
mutableStateFlow.update { result ->
val biometricCapability = biometricInteractor.capability()
val biometricEnabled = biometricInteractor.biometricEnabled
if (biometricEnabled && (!biometricCapability.available || !biometricCapability.enrolled)) {
biometricInteractor.biometricEnabled = false
snackbarInteractor.showMessage(
SnackbarMessage.Simple("Biometrics were disabled because device capability changed.")
)
}
result.copy(
encryption = dbIsEncrypted,
biometricEnabled = biometricInteractor.biometricEnabled,
biometricAvailable = biometricCapability.available,
biometricEnrolled = biometricCapability.enrolled,
biometricNeedsPasswordConfirmation = !biometricInteractor.biometricConfirmed,
language = localeInteractor.languageEnum,
appVersion = appVersionUseCase.invoke()
)
Expand Down Expand Up @@ -150,6 +167,45 @@ class SettingsViewModel(
}
}

private fun changeBiometric(checked: Boolean, password: String?) = viewModelScope.launch {
CountingIdlingRes.increment()
mutableStateFlow.update(SettingsResult::showLoading)
try {
val capability = biometricInteractor.capability()
when {
!checked -> biometricInteractor.biometricEnabled = false
!capability.available || !capability.enrolled -> {
biometricInteractor.biometricEnabled = false
snackbarInteractor.showMessage(SnackbarMessage.Simple("Biometrics are not available on this device."))
}
!dbIsEncrypted -> {
biometricInteractor.biometricEnabled = false
snackbarInteractor.showMessage(
SnackbarMessage.Simple("Enable password protection before turning on biometrics.")
)
}
!biometricInteractor.biometricConfirmed -> {
if (password.isNullOrEmpty()) {
snackbarInteractor.showMessage(SnackbarMessage.Simple("Confirm your password to enable biometrics."))
} else if (checkPasswordUseCase(password)) {
biometricInteractor.biometricConfirmed = true
biometricInteractor.biometricEnabled = true
} else {
biometricInteractor.biometricEnabled = false
snackbarInteractor.showMessage(SnackbarMessage.Simple("Incorrect password. Biometrics remain off."))
}
}
else -> biometricInteractor.biometricEnabled = true
}
updateSwitches()
} catch (e: Throwable) {
handleError(e) { "error changing biometrics" }
} finally {
mutableStateFlow.update(SettingsResult::hideLoading)
CountingIdlingRes.decrement()
}
}

private fun showCipherVersion() = viewModelScope.launch {
CountingIdlingRes.increment()
mutableStateFlow.update(SettingsResult::showLoading)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.softartdev.notedelight.interactor

import platform.Foundation.NSUserDefaults

actual class BiometricInteractor {
actual var biometricEnabled: Boolean
get() = NSUserDefaults.standardUserDefaults.boolForKey(KEY_BIOMETRIC_ENABLED)
set(value) {
NSUserDefaults.standardUserDefaults.setBool(value, KEY_BIOMETRIC_ENABLED)
}

actual var biometricConfirmed: Boolean
get() = NSUserDefaults.standardUserDefaults.boolForKey(KEY_BIOMETRIC_CONFIRMED)
set(value) {
NSUserDefaults.standardUserDefaults.setBool(value, KEY_BIOMETRIC_CONFIRMED)
}

actual fun capability(): BiometricCapability = BiometricCapability(
available = false,
enrolled = false,
)

private companion object {
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
private const val KEY_BIOMETRIC_CONFIRMED = "biometric_confirmed"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.softartdev.notedelight.interactor

import java.util.prefs.Preferences

actual class BiometricInteractor {
private val preferences = Preferences.userRoot().node(PREFS_NODE)

actual var biometricEnabled: Boolean
get() = preferences.getBoolean(KEY_BIOMETRIC_ENABLED, false)
set(value) {
preferences.putBoolean(KEY_BIOMETRIC_ENABLED, value)
}

actual var biometricConfirmed: Boolean
get() = preferences.getBoolean(KEY_BIOMETRIC_CONFIRMED, false)
set(value) {
preferences.putBoolean(KEY_BIOMETRIC_CONFIRMED, value)
}

actual fun capability(): BiometricCapability = BiometricCapability(
available = false,
enrolled = false,
)

private companion object {
private const val PREFS_NODE = "com.softartdev.notedelight.settings"
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
private const val KEY_BIOMETRIC_CONFIRMED = "biometric_confirmed"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.softartdev.notedelight.interactor

import kotlinx.browser.window

actual class BiometricInteractor {
actual var biometricEnabled: Boolean
get() = localStorageItem(KEY_BIOMETRIC_ENABLED) == TRUE_VALUE
set(value) {
setLocalStorageItem(KEY_BIOMETRIC_ENABLED, value.toString())
}

actual var biometricConfirmed: Boolean
get() = localStorageItem(KEY_BIOMETRIC_CONFIRMED) == TRUE_VALUE
set(value) {
setLocalStorageItem(KEY_BIOMETRIC_CONFIRMED, value.toString())
}

actual fun capability(): BiometricCapability = BiometricCapability(
available = false,
enrolled = false,
)

private companion object {
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
private const val KEY_BIOMETRIC_CONFIRMED = "biometric_confirmed"
private const val TRUE_VALUE = "true"
}
}

private fun localStorageItem(key: String): String? = window.localStorage.getItem(key)

private fun setLocalStorageItem(key: String, value: String) = window.localStorage.setItem(key, value)
Loading
Loading