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 00000000..cad0537d Binary files /dev/null and b/image/roborazzi/money_flow_locked.png differ 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..601b21dd --- /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("Unlock MoneyFlow") + .setSubtitle("Use biometrics or device credential") + .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..0b79a8ab 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.LAPolicyDeviceOwnerAuthentication +@OptIn(ExperimentalForeignApi::class) class IosBiometricAvailabilityChecker : BiometricAvailabilityChecker { - override fun isBiometricSupported(): Boolean = false + override fun isBiometricSupported(): Boolean { + val context = LAContext() + return context.canEvaluatePolicy(LAPolicyDeviceOwnerAuthentication, 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(), + ) +}