From c709c530433de18f17a3039fc0c8a2943e68d558 Mon Sep 17 00:00:00 2001 From: Marco Gomiero Date: Wed, 10 Dec 2025 23:04:06 +0100 Subject: [PATCH] feat: Implement biometric authentication for MoneyFlow app, refactor MainActivity, and add platform-specific biometric support --- .../com/prof18/moneyflow/MainActivity.kt | 111 ++---------------- image/roborazzi/money_flow_locked.png | Bin 0 -> 34464 bytes iosApp/Assets/Info.plist | 6 +- .../AndroidBiometricAuthenticator.kt | 71 +++++++++++ .../moneyflow/MoneyFlowLockedRoborazziTest.kt | 102 ++++++++++++++++ .../com/prof18/moneyflow/MainViewModel.kt | 42 ++++++- .../authentication/BiometricAuthenticator.kt | 11 ++ .../moneyflow/presentation/MoneyFlowApp.kt | 53 +++++++++ .../moneyflow/IosBiometricAuthenticator.kt | 38 ++++++ .../IosBiometricAvailabilityChecker.kt | 9 +- .../prof18/moneyflow/MainViewController.kt | 8 +- 11 files changed, 340 insertions(+), 111 deletions(-) create mode 100644 image/roborazzi/money_flow_locked.png create mode 100644 shared/src/androidMain/kotlin/com/prof18/moneyflow/AndroidBiometricAuthenticator.kt create mode 100644 shared/src/androidUnitTest/kotlin/com/prof18/moneyflow/MoneyFlowLockedRoborazziTest.kt create mode 100644 shared/src/commonMain/kotlin/com/prof18/moneyflow/features/authentication/BiometricAuthenticator.kt create mode 100644 shared/src/commonMain/kotlin/com/prof18/moneyflow/presentation/MoneyFlowApp.kt create mode 100644 shared/src/iosMain/kotlin/com/prof18/moneyflow/IosBiometricAuthenticator.kt diff --git a/androidApp/src/main/kotlin/com/prof18/moneyflow/MainActivity.kt b/androidApp/src/main/kotlin/com/prof18/moneyflow/MainActivity.kt index b3e81e7f..95b3098d 100644 --- a/androidApp/src/main/kotlin/com/prof18/moneyflow/MainActivity.kt +++ b/androidApp/src/main/kotlin/com/prof18/moneyflow/MainActivity.kt @@ -7,38 +7,15 @@ import android.view.WindowManager import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG -import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL -import androidx.biometric.BiometricPrompt -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material3.Surface -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity -import co.touchlab.kermit.Logger -import com.prof18.moneyflow.navigation.MoneyFlowNavHost -import com.prof18.moneyflow.presentation.auth.AuthScreen -import com.prof18.moneyflow.presentation.auth.AuthState -import com.prof18.moneyflow.ui.style.MoneyFlowTheme +import com.prof18.moneyflow.presentation.MoneyFlowApp import org.koin.androidx.viewmodel.ext.android.viewModel class MainActivity : FragmentActivity() { - private lateinit var biometricPrompt: BiometricPrompt - private lateinit var promptInfo: BiometricPrompt.PromptInfo - private val viewModel: MainViewModel by viewModel() - private var authState: AuthState by mutableStateOf(AuthState.AUTH_IN_PROGRESS) + private val biometricAuthenticator by lazy { AndroidBiometricAuthenticator(this) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -54,34 +31,15 @@ class MainActivity : FragmentActivity() { darkScrim = Color.TRANSPARENT, ) { isDarkMode }, ) - setupAuthentication() - if (!viewModel.isBiometricEnabled()) { - authState = AuthState.AUTHENTICATED - } - window.setFlags( WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE, ) setContent { - MoneyFlowTheme { - Box( - modifier = Modifier.fillMaxSize(), - ) { - MoneyFlowNavHost() - - AnimatedVisibility(visible = authState != AuthState.AUTHENTICATED) { - Surface( - modifier = Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.safeDrawing), - ) { - AuthScreen(authState = authState, onRetryClick = { performAuth() }) - } - } - } - } + MoneyFlowApp( + biometricAuthenticator = biometricAuthenticator, + ) } } @@ -92,65 +50,10 @@ class MainActivity : FragmentActivity() { override fun onStop() { super.onStop() - if (viewModel.isBiometricEnabled() && isBiometricSupported()) { - authState = AuthState.NOT_AUTHENTICATED - } + viewModel.lockIfNeeded(biometricAuthenticator) } private fun performAuth() { - if (viewModel.isBiometricEnabled() && isBiometricSupported()) { - authState = AuthState.AUTH_IN_PROGRESS - biometricPrompt.authenticate(promptInfo) - } - } - - private fun setupAuthentication() { - val executor = ContextCompat.getMainExecutor(this@MainActivity) - biometricPrompt = BiometricPrompt( - this@MainActivity, executor, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError( - errorCode: Int, - errString: CharSequence, - ) { - super.onAuthenticationError(errorCode, errString) - authState = AuthState.AUTH_ERROR - } - - override fun onAuthenticationSucceeded( - result: BiometricPrompt.AuthenticationResult, - ) { - super.onAuthenticationSucceeded(result) - authState = AuthState.AUTHENTICATED - } - - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - authState = AuthState.NOT_AUTHENTICATED - } - }, - ) - - promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle("Biometric login for my app") - .setSubtitle("Log in using your biometric credential") - // Can't call setNegativeButtonText() and - // setAllowedAuthenticators(... or DEVICE_CREDENTIAL) at the same time. - // .setNegativeButtonText("Use account password") - // if (allowDeviceCredential) setAllowedAuthenticators(BIOMETRIC_STRONG or BIOMETRIC_WEAK or ) - // else setNegativeButtonText("Cancel") - .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) - .build() - } - - private fun isBiometricSupported(): Boolean { - val biometricManager = BiometricManager.from(this) - return when (biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)) { - BiometricManager.BIOMETRIC_SUCCESS -> true - else -> { - Logger.d { "Reached some auth state. It should be impossible to reach this state!" } - false - } - } + viewModel.performAuthentication(biometricAuthenticator) } } diff --git a/image/roborazzi/money_flow_locked.png b/image/roborazzi/money_flow_locked.png new file mode 100644 index 0000000000000000000000000000000000000000..cad0537d4b1b8076bd3367bfef9bfb4ff7873ee2 GIT binary patch literal 34464 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 S{N4qUUIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - + CADisableMinimumFrameDurationOnPhone + + NSFaceIDUsageDescription + Unlock your MoneyFlow data with Face ID. ITSAppUsesNonExemptEncryption diff --git a/shared/src/androidMain/kotlin/com/prof18/moneyflow/AndroidBiometricAuthenticator.kt b/shared/src/androidMain/kotlin/com/prof18/moneyflow/AndroidBiometricAuthenticator.kt new file mode 100644 index 00000000..579f0d9b --- /dev/null +++ b/shared/src/androidMain/kotlin/com/prof18/moneyflow/AndroidBiometricAuthenticator.kt @@ -0,0 +1,71 @@ +package com.prof18.moneyflow + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import com.prof18.moneyflow.features.authentication.BiometricAuthenticator + +class AndroidBiometricAuthenticator( + private val activity: FragmentActivity, +) : BiometricAuthenticator { + + private var onSuccess: (() -> Unit)? = null + private var onFailure: (() -> Unit)? = null + private var onError: (() -> Unit)? = null + + private val biometricPrompt: BiometricPrompt by lazy { + val executor = ContextCompat.getMainExecutor(activity) + BiometricPrompt( + activity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError( + errorCode: Int, + errString: CharSequence, + ) { + super.onAuthenticationError(errorCode, errString) + onError?.invoke() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + onSuccess?.invoke() + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + onFailure?.invoke() + } + }, + ) + } + + private val promptInfo: BiometricPrompt.PromptInfo by lazy { + BiometricPrompt.PromptInfo.Builder() + .setTitle("MoneyFlow") + .setSubtitle("Unlock MoneyFlow") + .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) + .build() + } + + override fun canAuthenticate(): Boolean { + val biometricManager = BiometricManager.from(activity) + return biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == + BiometricManager.BIOMETRIC_SUCCESS + } + + override fun authenticate( + onSuccess: () -> Unit, + onFailure: () -> Unit, + onError: () -> Unit, + ) { + this.onSuccess = onSuccess + this.onFailure = onFailure + this.onError = onError + + biometricPrompt.authenticate(promptInfo) + } +} diff --git a/shared/src/androidUnitTest/kotlin/com/prof18/moneyflow/MoneyFlowLockedRoborazziTest.kt b/shared/src/androidUnitTest/kotlin/com/prof18/moneyflow/MoneyFlowLockedRoborazziTest.kt new file mode 100644 index 00000000..3e729e94 --- /dev/null +++ b/shared/src/androidUnitTest/kotlin/com/prof18/moneyflow/MoneyFlowLockedRoborazziTest.kt @@ -0,0 +1,102 @@ +package com.prof18.moneyflow + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.prof18.moneyflow.data.MoneyRepository +import com.prof18.moneyflow.data.SettingsRepository +import com.prof18.moneyflow.data.settings.SettingsSource +import com.prof18.moneyflow.features.addtransaction.AddTransactionViewModel +import com.prof18.moneyflow.features.alltransactions.AllTransactionsViewModel +import com.prof18.moneyflow.features.authentication.BiometricAuthenticator +import com.prof18.moneyflow.features.categories.CategoriesViewModel +import com.prof18.moneyflow.features.home.HomeViewModel +import com.prof18.moneyflow.features.settings.BiometricAvailabilityChecker +import com.prof18.moneyflow.features.settings.SettingsViewModel +import com.prof18.moneyflow.presentation.MoneyFlowApp +import com.prof18.moneyflow.presentation.MoneyFlowErrorMapper +import com.prof18.moneyflow.ui.style.MoneyFlowTheme +import com.prof18.moneyflow.utilities.closeDriver +import com.prof18.moneyflow.utilities.createDriver +import com.prof18.moneyflow.utilities.getDatabaseHelper +import com.russhwolf.settings.MapSettings +import com.russhwolf.settings.Settings +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config( + sdk = [33], + qualifiers = RobolectricDeviceQualifiers.Pixel7Pro, +) +class MoneyFlowLockedRoborazziTest : RoborazziTestBase() { + + private val fakeBiometricAuthenticator = object : BiometricAuthenticator { + override fun canAuthenticate(): Boolean = true + + override fun authenticate( + onSuccess: () -> Unit, + onFailure: () -> Unit, + onError: () -> Unit, + ) { + onFailure() + } + } + + @Before + fun setup() { + createDriver() + stopKoin() // Ensure Koin is stopped before starting + val koinApplication = startKoin { + modules( + module { + single { getDatabaseHelper() } + single { MapSettings() } + single { SettingsSource(get()) } + single { SettingsRepository(get()) } + single { MoneyRepository(get()) } + single { MoneyFlowErrorMapper() } + single { + object : BiometricAvailabilityChecker { + override fun isBiometricSupported(): Boolean = true + } + } + viewModel { HomeViewModel(get(), get(), get()) } + viewModel { AddTransactionViewModel(get(), get()) } + viewModel { CategoriesViewModel(get(), get()) } + viewModel { AllTransactionsViewModel(get(), get()) } + viewModel { SettingsViewModel(get()) } + viewModel { MainViewModel(get(), get()) } + }, + ) + } + koinApplication.koin.get().setBiometric(true) + } + + @After + fun teardownResources() { + stopKoin() + closeDriver() + } + + @Test + fun captureMoneyFlowLockedUi() { + composeRule.setContent { + MoneyFlowTheme { + MoneyFlowApp( + biometricAuthenticator = fakeBiometricAuthenticator, + ) + } + } + + capture("money_flow_locked") + } +} diff --git a/shared/src/commonMain/kotlin/com/prof18/moneyflow/MainViewModel.kt b/shared/src/commonMain/kotlin/com/prof18/moneyflow/MainViewModel.kt index 4cf57ac4..33294131 100644 --- a/shared/src/commonMain/kotlin/com/prof18/moneyflow/MainViewModel.kt +++ b/shared/src/commonMain/kotlin/com/prof18/moneyflow/MainViewModel.kt @@ -2,12 +2,50 @@ package com.prof18.moneyflow import androidx.lifecycle.ViewModel import com.prof18.moneyflow.data.SettingsRepository +import com.prof18.moneyflow.features.authentication.BiometricAuthenticator +import com.prof18.moneyflow.features.settings.BiometricAvailabilityChecker +import com.prof18.moneyflow.presentation.auth.AuthState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow class MainViewModel( private val settingsRepository: SettingsRepository, + private val biometricAvailabilityChecker: BiometricAvailabilityChecker, ) : ViewModel() { - fun isBiometricEnabled(): Boolean { - return settingsRepository.isBiometricEnabled() + private val _authState = MutableStateFlow(initialState()) + val authState: StateFlow = _authState + + fun performAuthentication(biometricAuthenticator: BiometricAuthenticator) { + if (!shouldUseBiometrics(biometricAuthenticator)) { + _authState.value = AuthState.AUTHENTICATED + return + } + + _authState.value = AuthState.AUTH_IN_PROGRESS + biometricAuthenticator.authenticate( + onSuccess = { _authState.value = AuthState.AUTHENTICATED }, + onFailure = { _authState.value = AuthState.NOT_AUTHENTICATED }, + onError = { _authState.value = AuthState.AUTH_ERROR }, + ) + } + + fun lockIfNeeded(biometricAuthenticator: BiometricAuthenticator) { + if (shouldUseBiometrics(biometricAuthenticator)) { + _authState.value = AuthState.NOT_AUTHENTICATED + } + } + + private fun initialState(): AuthState { + return if (settingsRepository.isBiometricEnabled()) { + AuthState.NOT_AUTHENTICATED + } else { + AuthState.AUTHENTICATED + } + } + + private fun shouldUseBiometrics(biometricAuthenticator: BiometricAuthenticator): Boolean { + return settingsRepository.isBiometricEnabled() && biometricAuthenticator.canAuthenticate() && + biometricAvailabilityChecker.isBiometricSupported() } } diff --git a/shared/src/commonMain/kotlin/com/prof18/moneyflow/features/authentication/BiometricAuthenticator.kt b/shared/src/commonMain/kotlin/com/prof18/moneyflow/features/authentication/BiometricAuthenticator.kt new file mode 100644 index 00000000..4cdc3a19 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/prof18/moneyflow/features/authentication/BiometricAuthenticator.kt @@ -0,0 +1,11 @@ +package com.prof18.moneyflow.features.authentication + +interface BiometricAuthenticator { + fun canAuthenticate(): Boolean + + fun authenticate( + onSuccess: () -> Unit, + onFailure: () -> Unit, + onError: () -> Unit, + ) +} diff --git a/shared/src/commonMain/kotlin/com/prof18/moneyflow/presentation/MoneyFlowApp.kt b/shared/src/commonMain/kotlin/com/prof18/moneyflow/presentation/MoneyFlowApp.kt new file mode 100644 index 00000000..f538f8ec --- /dev/null +++ b/shared/src/commonMain/kotlin/com/prof18/moneyflow/presentation/MoneyFlowApp.kt @@ -0,0 +1,53 @@ +package com.prof18.moneyflow.presentation + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.prof18.moneyflow.MainViewModel +import com.prof18.moneyflow.features.authentication.BiometricAuthenticator +import com.prof18.moneyflow.navigation.MoneyFlowNavHost +import com.prof18.moneyflow.presentation.auth.AuthScreen +import com.prof18.moneyflow.presentation.auth.AuthState +import com.prof18.moneyflow.ui.style.MoneyFlowTheme +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun MoneyFlowApp( + biometricAuthenticator: BiometricAuthenticator, + modifier: Modifier = Modifier, +) { + val viewModel = koinViewModel() + val authState by viewModel.authState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.performAuthentication(biometricAuthenticator) + } + + MoneyFlowTheme { + Box(modifier = modifier.fillMaxSize()) { + MoneyFlowNavHost() + + AnimatedVisibility(visible = authState != AuthState.AUTHENTICATED) { + Surface( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing), + ) { + AuthScreen( + authState = authState, + onRetryClick = { viewModel.performAuthentication(biometricAuthenticator) }, + ) + } + } + } + } +} diff --git a/shared/src/iosMain/kotlin/com/prof18/moneyflow/IosBiometricAuthenticator.kt b/shared/src/iosMain/kotlin/com/prof18/moneyflow/IosBiometricAuthenticator.kt new file mode 100644 index 00000000..ca8a4720 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/prof18/moneyflow/IosBiometricAuthenticator.kt @@ -0,0 +1,38 @@ +package com.prof18.moneyflow + +import com.prof18.moneyflow.features.authentication.BiometricAuthenticator +import kotlinx.cinterop.ExperimentalForeignApi +import platform.LocalAuthentication.LAContext +import platform.LocalAuthentication.LAPolicyDeviceOwnerAuthentication +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue + +@OptIn(ExperimentalForeignApi::class) +class IosBiometricAuthenticator : BiometricAuthenticator { + + override fun canAuthenticate(): Boolean { + val context = LAContext() + return context.canEvaluatePolicy(LAPolicyDeviceOwnerAuthentication, null) + } + + override fun authenticate( + onSuccess: () -> Unit, + onFailure: () -> Unit, + onError: () -> Unit, + ) { + val context = LAContext() + context.evaluatePolicy( + policy = LAPolicyDeviceOwnerAuthentication, + localizedReason = "Unlock MoneyFlow", + reply = { success, error -> + dispatch_async(dispatch_get_main_queue()) { + when { + success -> onSuccess() + error != null -> onError() + else -> onFailure() + } + } + }, + ) + } +} diff --git a/shared/src/iosMain/kotlin/com/prof18/moneyflow/IosBiometricAvailabilityChecker.kt b/shared/src/iosMain/kotlin/com/prof18/moneyflow/IosBiometricAvailabilityChecker.kt index d8575c72..a796ee1a 100644 --- a/shared/src/iosMain/kotlin/com/prof18/moneyflow/IosBiometricAvailabilityChecker.kt +++ b/shared/src/iosMain/kotlin/com/prof18/moneyflow/IosBiometricAvailabilityChecker.kt @@ -1,7 +1,14 @@ package com.prof18.moneyflow import com.prof18.moneyflow.features.settings.BiometricAvailabilityChecker +import kotlinx.cinterop.ExperimentalForeignApi +import platform.LocalAuthentication.LAContext +import platform.LocalAuthentication.LAPolicyDeviceOwnerAuthenticationWithBiometrics +@OptIn(ExperimentalForeignApi::class) class IosBiometricAvailabilityChecker : BiometricAvailabilityChecker { - override fun isBiometricSupported(): Boolean = false + override fun isBiometricSupported(): Boolean { + val context = LAContext() + return context.canEvaluatePolicy(LAPolicyDeviceOwnerAuthenticationWithBiometrics, null) + } } diff --git a/shared/src/iosMain/kotlin/com/prof18/moneyflow/MainViewController.kt b/shared/src/iosMain/kotlin/com/prof18/moneyflow/MainViewController.kt index 9bdb9eb2..6fffe323 100644 --- a/shared/src/iosMain/kotlin/com/prof18/moneyflow/MainViewController.kt +++ b/shared/src/iosMain/kotlin/com/prof18/moneyflow/MainViewController.kt @@ -1,7 +1,11 @@ package com.prof18.moneyflow import androidx.compose.ui.window.ComposeUIViewController -import com.prof18.moneyflow.navigation.MoneyFlowNavHost +import com.prof18.moneyflow.presentation.MoneyFlowApp @Suppress("FunctionName") -fun MainViewController() = ComposeUIViewController { MoneyFlowNavHost() } +fun MainViewController() = ComposeUIViewController { + MoneyFlowApp( + biometricAuthenticator = IosBiometricAuthenticator(), + ) +}