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
1 change: 1 addition & 0 deletions core/presentation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ kotlin {
}
androidMain.dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.androidx.biometric)
}
val androidHostTest by getting {
dependencies {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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.BiometricAuthService
import com.softartdev.notedelight.navigation.AppNavGraph
import com.softartdev.notedelight.navigation.Router
import com.softartdev.notedelight.presentation.MainDispatcherRule
Expand All @@ -29,12 +30,14 @@ class SignInViewModelTest {
private val mockCheckPasswordUseCase = Mockito.mock(CheckPasswordUseCase::class.java)
private val mockRouter = Mockito.mock(Router::class.java)
private val mockAutofillManager = Mockito.mock(AutofillManager::class.java)
private val mockBiometricAuthService = Mockito.mock(BiometricAuthService::class.java)

private lateinit var signInViewModel: SignInViewModel

@Before
fun setUp() {
signInViewModel = SignInViewModel(mockCheckPasswordUseCase, mockRouter)
Mockito.`when`(mockBiometricAuthService.isBiometricAvailable()).thenReturn(false)
signInViewModel = SignInViewModel(mockCheckPasswordUseCase, mockRouter, mockBiometricAuthService)
signInViewModel.autofillManager = mockAutofillManager
}

Expand Down Expand Up @@ -114,4 +117,4 @@ class SignInViewModelTest {
cancelAndIgnoreRemainingEvents()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.softartdev.notedelight.interactor

import android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import androidx.core.content.ContextCompat
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume

class AndroidBiometricAuthService(private val context: Context) : BiometricAuthService {

override suspend fun isBiometricAvailable(): Boolean {
val biometricManager = BiometricManager.from(context)
val authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG
return biometricManager.canAuthenticate(authenticators) == BiometricManager.BIOMETRIC_SUCCESS
}

override suspend fun authenticate(): BiometricAuthResult = suspendCancellableCoroutine { continuation ->
val activity = context.findActivity() as? FragmentActivity
if (activity == null) {
continuation.resume(BiometricAuthResult.FallbackToPassword)
return@suspendCancellableCoroutine
}
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric authentication")
.setSubtitle("Sign in to NoteDelight")
.setNegativeButtonText("Use password")
.build()
val biometricPrompt = BiometricPrompt(
activity,
ContextCompat.getMainExecutor(activity),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
if (continuation.isActive) {
continuation.resume(BiometricAuthResult.Success)
}
}

override fun onAuthenticationFailed() {
if (continuation.isActive) {
continuation.resume(BiometricAuthResult.Failed)
}
Comment on lines +40 to +43
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 Do not finish auth flow on non-terminal biometric failure

onAuthenticationFailed() is a non-terminal callback (e.g., one bad fingerprint while the prompt remains open), but this code resumes the coroutine as Failed immediately. That ends the sign-in flow on the first mismatch and can ignore a later successful scan from the same prompt session, producing incorrect login failures.

Useful? React with 👍 / 👎.

}

override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (!continuation.isActive) return
val authResult = when (errorCode) {
BiometricPrompt.ERROR_NEGATIVE_BUTTON,
BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_CANCELED -> BiometricAuthResult.FallbackToPassword
else -> BiometricAuthResult.Failed
}
continuation.resume(authResult)
}
}
)
biometricPrompt.authenticate(promptInfo)
}
}

private tailrec fun Context.findActivity(): android.app.Activity? = when (this) {
is android.app.Activity -> this
is android.content.ContextWrapper -> baseContext.findActivity()
else -> null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.softartdev.notedelight.interactor

interface BiometricAuthService {
suspend fun isBiometricAvailable(): Boolean
suspend fun authenticate(): BiometricAuthResult
}

sealed interface BiometricAuthResult {
data object Success : BiometricAuthResult
data object Failed : BiometricAuthResult
data object FallbackToPassword : BiometricAuthResult
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ package com.softartdev.notedelight.presentation.signin
sealed interface SignInAction {
data object OnSettingsClick : SignInAction
data class OnSignInClick(val pass: CharSequence) : SignInAction
data object OnBiometricClick : SignInAction
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,10 @@ enum class SignInResult(val isError: Boolean = false) {
ShowSignInForm,
ShowProgress,
ShowEmptyPassError(isError = true),
ShowIncorrectPassError(isError = true)
ShowIncorrectPassError(isError = true),
ShowBiometricAvailable,
ShowBiometricInProgress,
ShowBiometricSuccess,
ShowBiometricFailed(isError = true),
ShowBiometricFallbackToPassword
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import androidx.compose.ui.autofill.AutofillManager
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import com.softartdev.notedelight.interactor.BiometricAuthResult
import com.softartdev.notedelight.interactor.BiometricAuthService
import com.softartdev.notedelight.navigation.AppNavGraph
import com.softartdev.notedelight.navigation.Router
import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase
Expand All @@ -14,7 +16,8 @@ import kotlinx.coroutines.launch

class SignInViewModel(
private val checkPasswordUseCase: CheckPasswordUseCase,
private val router: Router
private val router: Router,
private val biometricAuthService: BiometricAuthService
) : ViewModel() {
private val logger = Logger.withTag(this@SignInViewModel::class.simpleName.toString())
private val mutableStateFlow: MutableStateFlow<SignInResult> = MutableStateFlow(
Expand All @@ -23,9 +26,20 @@ class SignInViewModel(
val stateFlow: StateFlow<SignInResult> = mutableStateFlow
var autofillManager: AutofillManager? = null

init {
checkBiometricAvailability()
}

fun onAction(action: SignInAction) = when (action) {
is SignInAction.OnSettingsClick -> router.navigateClearingBackStack(AppNavGraph.Settings)
is SignInAction.OnSignInClick -> signIn(action.pass)
is SignInAction.OnBiometricClick -> signInWithBiometric()
}

private fun checkBiometricAvailability() = viewModelScope.launch {
if (biometricAuthService.isBiometricAvailable()) {
mutableStateFlow.value = SignInResult.ShowBiometricAvailable
}
}

private fun signIn(pass: CharSequence) = viewModelScope.launch {
Expand All @@ -50,4 +64,25 @@ class SignInViewModel(
CountingIdlingRes.decrement()
}
}

private fun signInWithBiometric() = viewModelScope.launch {
CountingIdlingRes.increment()
mutableStateFlow.value = SignInResult.ShowBiometricInProgress
try {
when (biometricAuthService.authenticate()) {
BiometricAuthResult.Success -> {
router.navigateClearingBackStack(AppNavGraph.Main)
mutableStateFlow.value = SignInResult.ShowBiometricSuccess
}
BiometricAuthResult.Failed -> mutableStateFlow.value = SignInResult.ShowBiometricFailed
BiometricAuthResult.FallbackToPassword ->
mutableStateFlow.value = SignInResult.ShowBiometricFallbackToPassword
}
} catch (error: Throwable) {
logger.e(error) { "Error during biometric sign in" }
mutableStateFlow.value = SignInResult.ShowBiometricFallbackToPassword
} finally {
CountingIdlingRes.decrement()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.softartdev.notedelight.interactor

import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCObjectVar
import kotlinx.cinterop.alloc
import kotlinx.cinterop.memScoped
import kotlinx.coroutines.suspendCancellableCoroutine
import platform.LocalAuthentication.LAContext
import platform.LocalAuthentication.LAPolicyDeviceOwnerAuthenticationWithBiometrics
import platform.LocalAuthentication.LAErrorUserCancel
import platform.LocalAuthentication.LAErrorUserFallback
import kotlin.coroutines.resume

class IosBiometricAuthService : BiometricAuthService {

@OptIn(ExperimentalForeignApi::class)
override suspend fun isBiometricAvailable(): Boolean = memScoped {
val authContext = LAContext()
val errorPtr = alloc<ObjCObjectVar<platform.Foundation.NSError?>>()
authContext.canEvaluatePolicy(
policy = LAPolicyDeviceOwnerAuthenticationWithBiometrics,
error = errorPtr.ptr
)
}

override suspend fun authenticate(): BiometricAuthResult = suspendCancellableCoroutine { continuation ->
val authContext = LAContext()
authContext.evaluatePolicy(
policy = LAPolicyDeviceOwnerAuthenticationWithBiometrics,
localizedReason = "Authenticate to sign in"
) { success, error ->
if (!continuation.isActive) return@evaluatePolicy
val result = when {
success -> BiometricAuthResult.Success
error?.code?.toInt() == LAErrorUserFallback || error?.code?.toInt() == LAErrorUserCancel ->
BiometricAuthResult.FallbackToPassword
else -> BiometricAuthResult.Failed
}
continuation.resume(result)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.softartdev.notedelight.interactor

class JvmBiometricAuthService : BiometricAuthService {
override suspend fun isBiometricAvailable(): Boolean = false

override suspend fun authenticate(): BiometricAuthResult = BiometricAuthResult.FallbackToPassword
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.softartdev.notedelight.interactor

class WasmJsBiometricAuthService : BiometricAuthService {
override suspend fun isBiometricAvailable(): Boolean = false

override suspend fun authenticate(): BiometricAuthResult = BiometricAuthResult.FallbackToPassword
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.softartdev.notedelight.di

import android.content.Context
import com.softartdev.notedelight.interactor.AndroidBiometricAuthService
import com.softartdev.notedelight.interactor.BiometricAuthService
import com.softartdev.notedelight.repository.AndroidFileRepo
import com.softartdev.notedelight.repository.AndroidSafeRepo
import com.softartdev.notedelight.repository.FileRepo
Expand All @@ -20,3 +22,6 @@ actual val repoModule: Module = module {

actual fun Module.factoryOfAppVersionUseCase(): KoinDefinition<AppVersionUseCase> =
factoryOf<AppVersionUseCase, Context>(constructor = ::AppVersionUseCase)

actual fun Module.singleOfBiometricAuthService(): KoinDefinition<BiometricAuthService> =
factoryOf<BiometricAuthService, Context>(constructor = ::AndroidBiometricAuthService)
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 Inject an Activity context for biometric auth

This binding creates AndroidBiometricAuthService from Koin's generic Context, but Android Koin is initialized with the application context (androidContext(this@MainApplication)), not an Activity. As a result, authenticate() cannot obtain a FragmentActivity and immediately returns FallbackToPassword, so Android users will never see a biometric prompt even when isBiometricAvailable() is true.

Useful? React with 👍 / 👎.

2 changes: 2 additions & 0 deletions core/ui/src/commonMain/composeResources/values-ru/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<string name="note_save_change_dialog_message">Сохранить изменения?</string>
<string name="note_empty">Заметка пуста. Введите данные.</string>
<string name="sign_in">Войти</string>
<string name="sign_in_with_biometrics">Войти по биометрии</string>
<string name="security">Безопасность</string>
<string name="pref_title_enable_encryption">Включить шифрование</string>
<string name="pref_title_set_password">Установить пароль</string>
Expand All @@ -33,6 +34,7 @@
<string name="enter_new_password">Введите новый пароль</string>
<string name="repeat_new_password">Повторите новый пароль</string>
<string name="incorrect_password">Неверный пароль</string>
<string name="biometric_auth_failed">Сбой биометрии. Используйте пароль.</string>
<string name="empty_password">Пароль не должен быть пустым</string>
<string name="passwords_do_not_match">Пароли не совпадают</string>
<string name="label_empty_result">Пока нет заметок</string>
Expand Down
2 changes: 2 additions & 0 deletions core/ui/src/commonMain/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<string name="note_save_change_dialog_message">Do you want to save the changes?</string>
<string name="note_empty">The note is empty. Enter the data.</string>
<string name="sign_in">Sign in</string>
<string name="sign_in_with_biometrics">Sign in with biometrics</string>
<string name="security">Security</string>
<string name="pref_title_enable_encryption">Enable encryption</string>
<string name="pref_title_set_password">Set password</string>
Expand All @@ -33,6 +34,7 @@
<string name="enter_new_password">Enter new password</string>
<string name="repeat_new_password">Repeat new password</string>
<string name="incorrect_password">Incorrect password</string>
<string name="biometric_auth_failed">Biometric auth failed. Use password.</string>
<string name="empty_password">Password must not be empty</string>
<string name="passwords_do_not_match">Passwords do not match</string>
<string name="label_empty_result">No notes yet</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.softartdev.notedelight.di

import com.softartdev.notedelight.db.NoteDAO
import com.softartdev.notedelight.interactor.BiometricAuthService
import com.softartdev.notedelight.presentation.files.FilesViewModel
import com.softartdev.notedelight.presentation.main.MainViewModel
import com.softartdev.notedelight.presentation.note.DeleteViewModel
Expand Down Expand Up @@ -49,6 +50,7 @@ val daoModule: Module = module {
}

val useCaseModule: Module = module {
singleOfBiometricAuthService()
factoryOf(::ChangePasswordUseCase)
factoryOf(::CheckPasswordUseCase)
factoryOf(::CheckSqlCipherVersionUseCase)
Expand Down Expand Up @@ -82,3 +84,4 @@ val viewModelModule: Module = module {
}

expect fun Module.factoryOfAppVersionUseCase(): KoinDefinition<AppVersionUseCase>
expect fun Module.singleOfBiometricAuthService(): KoinDefinition<BiometricAuthService>
Loading
Loading