Skip to content
Merged
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
111 changes: 7 additions & 104 deletions androidApp/src/main/kotlin/com/prof18/moneyflow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
)
}
}

Expand All @@ -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)
}
}
Binary file added image/roborazzi/money_flow_locked.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 4 additions & 2 deletions iosApp/Assets/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>NSFaceIDUsageDescription</key>
<string>Unlock your MoneyFlow data with Face ID.</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</dict>
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<Settings> { MapSettings() }
single { SettingsSource(get()) }
single { SettingsRepository(get()) }
single { MoneyRepository(get()) }
single { MoneyFlowErrorMapper() }
single<BiometricAvailabilityChecker> {
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<SettingsRepository>().setBiometric(true)
}

@After
fun teardownResources() {
stopKoin()
closeDriver()
}

@Test
fun captureMoneyFlowLockedUi() {
composeRule.setContent {
MoneyFlowTheme {
MoneyFlowApp(
biometricAuthenticator = fakeBiometricAuthenticator,
)
}
}

capture("money_flow_locked")
}
}
42 changes: 40 additions & 2 deletions shared/src/commonMain/kotlin/com/prof18/moneyflow/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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> = _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()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.prof18.moneyflow.features.authentication

interface BiometricAuthenticator {
fun canAuthenticate(): Boolean

fun authenticate(
onSuccess: () -> Unit,
onFailure: () -> Unit,
onError: () -> Unit,
)
}
Loading
Loading