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/iosApp/Assets/Info.plist b/iosApp/Assets/Info.plist
index 55e57033..2933eea6 100644
--- a/iosApp/Assets/Info.plist
+++ b/iosApp/Assets/Info.plist
@@ -39,8 +39,10 @@
UIInterfaceOrientationLandscapeLeft
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(),
+ )
+}