Compare commits

...

10 Commits

37 changed files with 872 additions and 42 deletions

View File

@ -1,7 +1,6 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
plugins { plugins {
@ -92,8 +91,11 @@ kotlin {
androidMain.dependencies { androidMain.dependencies {
implementation(compose.preview) implementation(compose.preview)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.biometric)
implementation(libs.sqldelight.android.driver) implementation(libs.sqldelight.android.driver)
implementation(libs.favre.bcrypt)
} }
nativeMain.dependencies { nativeMain.dependencies {
@ -110,6 +112,8 @@ kotlin {
implementation(libs.sqldelight.sqlite.driver) implementation(libs.sqldelight.sqlite.driver)
implementation(libs.favre.bcrypt)
implementation(libs.logback) implementation(libs.logback)
} }
} }
@ -140,8 +144,8 @@ android {
applicationId = "net.codinux.banking.android" // the appId of the old Bankmeister app to be able to use the old PlayStore entry applicationId = "net.codinux.banking.android" // the appId of the old Bankmeister app to be able to use the old PlayStore entry
minSdk = libs.versions.android.minSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 10 versionCode = 11
versionName = "1.0.0-Alpha-12" versionName = "1.0.0-Alpha-13"
} }
packaging { packaging {
resources { resources {

View File

@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<application <application
android:allowBackup="true" android:allowBackup="true"

View File

@ -1,21 +1,24 @@
package net.codinux.banking.ui package net.codinux.banking.ui
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.fragment.app.FragmentActivity
import app.cash.sqldelight.async.coroutines.synchronous import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.driver.android.AndroidSqliteDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import net.codinux.banking.dataaccess.BankmeisterDb import net.codinux.banking.dataaccess.BankmeisterDb
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.service.AuthenticationService
import net.codinux.banking.ui.service.BiometricAuthenticationService
import net.codinux.banking.ui.service.ImageService import net.codinux.banking.ui.service.ImageService
class MainActivity : ComponentActivity() { class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
ImageService.context = this.applicationContext ImageService.context = this.applicationContext
AuthenticationService.biometricAuthenticationService = BiometricAuthenticationService(this)
DI.setRepository(AndroidSqliteDriver(BankmeisterDb.Schema.synchronous(), this, "Bankmeister.db")) DI.setRepository(AndroidSqliteDriver(BankmeisterDb.Schema.synchronous(), this, "Bankmeister.db"))

View File

@ -0,0 +1,24 @@
package net.codinux.banking.ui.forms
import androidx.compose.runtime.*
import androidx.compose.ui.tooling.preview.Preview
@Preview
@Composable
fun SegmentedControlPreview() {
SegmentedControl(
options = listOf("Option 1", "Option 2", "Option 3"),
selectedOption = "Option 1",
onOptionSelected = { }
)
}
@Preview
@Composable
fun SegmentedControlPreview_OnlyTwoOptions() {
SegmentedControl(
options = listOf("Option 1", "Option 2"),
selectedOption = "Option 2",
onOptionSelected = { }
)
}

View File

@ -0,0 +1,15 @@
package net.codinux.banking.ui.screens
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
import net.codinux.banking.ui.model.settings.AppSettings
@Preview
@Composable
fun ProtectAppSettingsDialogPreview() {
val appSettings = AppSettings(AppAuthenticationMethod.Password)
ProtectAppSettingsDialog(appSettings) { }
}

View File

@ -0,0 +1,29 @@
package net.codinux.banking.ui.service
import at.favre.lib.crypto.bcrypt.BCrypt
import net.codinux.banking.ui.model.AuthenticationResult
actual object AuthenticationService {
internal var biometricAuthenticationService: BiometricAuthenticationService? = null
actual fun hashPassword(password: String): String =
BCrypt.withDefaults().hashToString(12, password.toCharArray())
actual fun checkPassword(password: String, hashedPassword: String): Boolean =
BCrypt.verifyer().verify(password.toCharArray(), hashedPassword).verified
actual val supportsBiometricAuthentication: Boolean
get() = biometricAuthenticationService?.supportsBiometricAuthentication ?: false
actual fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit) {
if (biometricAuthenticationService != null) {
biometricAuthenticationService!!.authenticate(null, authenticationResult)
} else {
authenticationResult(AuthenticationResult(false, "Biometrics is not supported"))
}
}
}

View File

@ -0,0 +1,66 @@
package net.codinux.banking.ui.service
import android.os.Build
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import net.codinux.banking.ui.R
import net.codinux.banking.ui.model.AuthenticationResult
import javax.crypto.Cipher
class BiometricAuthenticationService(
private val activity: FragmentActivity
) {
private val biometricManager: BiometricManager = BiometricManager.from(activity)
private val allowedAuthenticators = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) BIOMETRIC_STRONG
else BIOMETRIC_STRONG or BIOMETRIC_WEAK
val supportsBiometricAuthentication: Boolean by lazy {
biometricManager.canAuthenticate(allowedAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
}
fun authenticate(cipher: Cipher?, authenticationResult: (AuthenticationResult) -> Unit) {
val executor = ContextCompat.getMainExecutor(this.activity)
val biometricPrompt = BiometricPrompt(activity, executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) {
super.onAuthenticationError(errorCode, errorString)
authenticationResult(AuthenticationResult(false, errorString.toString()))
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
authenticationResult(AuthenticationResult(true))
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
authenticationResult(AuthenticationResult(false))
}
})
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(this.activity.getString(R.string.activity_login_authenticate_with_biometrics_prompt))
//.setSubtitle() // TODO: add subtitle?
.setNegativeButtonText(this.activity.getString(android.R.string.cancel)) // is not allowed when device credentials are allowed
.setAllowedAuthenticators(allowedAuthenticators)
.build()
if (cipher == null) {
biometricPrompt.authenticate(promptInfo)
} else {
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
}
}

View File

@ -1,3 +1,5 @@
<resources> <resources>
<string name="app_name">Bankmeister</string> <string name="app_name">Bankmeister</string>
<string name="activity_login_authenticate_with_biometrics_prompt">Authentifizieren Sich sich um die App zu entsperren</string>
</resources> </resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -9,6 +9,8 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
import net.codinux.banking.ui.screens.LoginScreen
import net.codinux.banking.ui.screens.MainScreen import net.codinux.banking.ui.screens.MainScreen
import net.codinux.log.LoggerFactory import net.codinux.log.LoggerFactory
import org.jetbrains.compose.ui.tooling.preview.Preview import org.jetbrains.compose.ui.tooling.preview.Preview
@ -25,13 +27,23 @@ fun App() {
val colors = MaterialTheme.colors.copy(primary = Colors.Primary, primaryVariant = Colors.PrimaryDark, onPrimary = Color.White, val colors = MaterialTheme.colors.copy(primary = Colors.Primary, primaryVariant = Colors.PrimaryDark, onPrimary = Color.White,
secondary = Colors.Accent, secondaryVariant = Colors.Accent, onSecondary = Color.White) secondary = Colors.Accent, secondaryVariant = Colors.Accent, onSecondary = Color.White)
val appSettings = DI.uiState.appSettings.collectAsState().value
var isLoggedIn by remember(appSettings.authenticationMethod) { mutableStateOf(appSettings.authenticationMethod == AppAuthenticationMethod.None) }
var isInitialized by remember { mutableStateOf(false) } var isInitialized by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
MaterialTheme(colors = colors, typography = typography) { MaterialTheme(colors = colors, typography = typography) {
MainScreen() if (isLoggedIn == false) {
LoginScreen(appSettings) {
isLoggedIn = true
}
} else {
MainScreen()
}
} }

View File

@ -7,6 +7,7 @@ import androidx.compose.material.Text
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.SaveAs import androidx.compose.material.icons.filled.SaveAs
import androidx.compose.material.icons.outlined.Key
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -124,6 +125,15 @@ fun SideMenuContent() {
drawerState.close() drawerState.close()
} }
} }
NavigationMenuItem(itemModifier, "Appzugang schützen", textColor, horizontalPadding = ItemHorizontalPadding,
icon = { Icon(Icons.Outlined.Key, "Appzugang durch Passwort oder Biometrieeingabe schützen", Modifier.size(iconSize), tint = textColor) }) {
uiState.showProtectAppSettingsScreen.value = true
coroutineScope.launch {
drawerState.close()
}
}
} }
} }
} }

View File

@ -6,9 +6,7 @@ import androidx.compose.runtime.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.dialogs.* import net.codinux.banking.ui.dialogs.*
import net.codinux.banking.ui.screens.AccountTransactionDetailsScreen import net.codinux.banking.ui.screens.*
import net.codinux.banking.ui.screens.BankSettingsScreen
import net.codinux.banking.ui.screens.ExportScreen
import net.codinux.banking.ui.state.UiState import net.codinux.banking.ui.state.UiState
private val formatUtil = DI.formatUtil private val formatUtil = DI.formatUtil
@ -17,9 +15,13 @@ private val formatUtil = DI.formatUtil
fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) { fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
val showAddAccountDialog by uiState.showAddAccountDialog.collectAsState() val showAddAccountDialog by uiState.showAddAccountDialog.collectAsState()
val showTransferMoneyDialogData by uiState.showTransferMoneyDialogData.collectAsState() val showTransferMoneyDialogData by uiState.showTransferMoneyDialogData.collectAsState()
val showAccountTransactionDetailsScreenForId by uiState.showAccountTransactionDetailsScreenForId.collectAsState() val showAccountTransactionDetailsScreenForId by uiState.showAccountTransactionDetailsScreenForId.collectAsState()
val showBankSettingsScreenForBank by uiState.showBankSettingsScreenForBank.collectAsState() val showBankSettingsScreenForBank by uiState.showBankSettingsScreenForBank.collectAsState()
val showBankAccountSettingsScreenForAccount by uiState.showBankAccountSettingsScreenForAccount.collectAsState()
val showExportScreen by uiState.showExportScreen.collectAsState() val showExportScreen by uiState.showExportScreen.collectAsState()
val showProtectAppSettingsScreen by uiState.showProtectAppSettingsScreen.collectAsState()
val tanChallengeReceived by uiState.tanChallengeReceived.collectAsState() val tanChallengeReceived by uiState.tanChallengeReceived.collectAsState()
val bankingClientError by uiState.bankingClientErrorOccurred.collectAsState() val bankingClientError by uiState.bankingClientErrorOccurred.collectAsState()
@ -36,6 +38,7 @@ fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
TransferMoneyDialog(data) { uiState.showTransferMoneyDialogData.value = null } TransferMoneyDialog(data) { uiState.showTransferMoneyDialogData.value = null }
} }
showAccountTransactionDetailsScreenForId?.let { transactionId -> showAccountTransactionDetailsScreenForId?.let { transactionId ->
DI.bankingService.getTransaction(transactionId)?.let { transaction -> DI.bankingService.getTransaction(transactionId)?.let { transaction ->
AccountTransactionDetailsScreen(transaction) { uiState.showAccountTransactionDetailsScreenForId.value = null } AccountTransactionDetailsScreen(transaction) { uiState.showAccountTransactionDetailsScreenForId.value = null }
@ -46,10 +49,19 @@ fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
BankSettingsScreen(bank) { uiState.showBankSettingsScreenForBank.value = null } BankSettingsScreen(bank) { uiState.showBankSettingsScreenForBank.value = null }
} }
showBankAccountSettingsScreenForAccount?.let { account ->
BankAccountSettingsScreen(account) { uiState.showBankAccountSettingsScreenForAccount.value = null }
}
if (showExportScreen) { if (showExportScreen) {
ExportScreen { uiState.showExportScreen.value = false } ExportScreen { uiState.showExportScreen.value = false }
} }
if (showProtectAppSettingsScreen) {
ProtectAppSettingsDialog(uiState.appSettings.value) { uiState.showProtectAppSettingsScreen.value = false }
}
tanChallengeReceived?.let { tanChallengeReceived -> tanChallengeReceived?.let { tanChallengeReceived ->
EnterTanDialog(tanChallengeReceived) { EnterTanDialog(tanChallengeReceived) {

View File

@ -0,0 +1,24 @@
package net.codinux.banking.ui.composables.authentification
import androidx.compose.foundation.layout.*
import androidx.compose.material.Button
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Fingerprint
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import net.codinux.banking.ui.model.AuthenticationResult
import net.codinux.banking.ui.service.AuthenticationService
import net.codinux.banking.ui.service.safelyAuthenticateWithBiometrics
@Composable
fun BiometricAuthenticationButton(authenticationResult: (AuthenticationResult) -> Unit) {
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.Center) {
Button({ AuthenticationService.safelyAuthenticateWithBiometrics(authenticationResult) }, enabled = AuthenticationService.supportsBiometricAuthentication) {
Icon(Icons.Outlined.Fingerprint, "Sich mittels Biometrie authentifizieren", Modifier.size(84.dp))
}
}
}

View File

@ -31,13 +31,13 @@ fun UiSettings(modifier: Modifier, textColor: Color = Color.Unspecified) {
Column(modifier) { Column(modifier) {
BooleanOption("Kontostand anzeigen", showBalance, textColor = textColor) { uiSettings.showBalance.value = it } BooleanOption("Kontostand anzeigen", showBalance, textColor = textColor) { uiSettings.showBalance.value = it }
BooleanOption("Umsätze in alternierenden Farben anzeigen", showTransactionsInAlternatingColors, textColor = textColor) { uiSettings.showTransactionsInAlternatingColors.value = it } BooleanOption("Umsätze in alternierenden Farben anzeigen", showTransactionsInAlternatingColors, textColor = textColor) { uiSettings.showTransactionsInAlternatingColors.value = it }
BooleanOption("Bank Icons anzeigen", showBankIcons, textColor = textColor) { uiSettings.showBankIcons.value = it } BooleanOption("Bank Icons anzeigen", showBankIcons, textColor = textColor) { uiSettings.showBankIcons.value = it }
BooleanOption("Umsätze farbig anzeigen", showColoredAmounts, textColor = textColor) { uiSettings.showColoredAmounts.value = it } BooleanOption("Umsätze farbig anzeigen", showColoredAmounts, textColor = textColor) { uiSettings.showColoredAmounts.value = it }
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("Umsätze gruppieren", color = textColor) Text("Umsätze gruppieren", color = textColor)

View File

@ -3,14 +3,15 @@ package net.codinux.banking.ui.composables.text
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import net.codinux.banking.ui.config.Style import net.codinux.banking.ui.config.Style
@Composable @Composable
fun HeaderText(title: String, modifier: Modifier = Modifier, textAlign: TextAlign = TextAlign.Start) { fun HeaderText(title: String, modifier: Modifier = Modifier, textAlign: TextAlign = TextAlign.Start, textColor: Color = Style.HeaderTextColor) {
Text( Text(
title, title,
color = Style.HeaderTextColor, color = textColor,
fontSize = Style.HeaderFontSize, fontSize = Style.HeaderFontSize,
fontWeight = Style.HeaderFontWeight, fontWeight = Style.HeaderFontWeight,
modifier = modifier, modifier = modifier,

View File

@ -43,6 +43,10 @@ object DI {
fun setRepository(repository: BankingRepository) { fun setRepository(repository: BankingRepository) {
this.bankingRepository = repository this.bankingRepository = repository
repository.getAppSettings()?.let { // otherwise it's the first app start, BankingService will take care of this case
uiState.appSettings.value = it
}
} }

View File

@ -1,7 +1,9 @@
package net.codinux.banking.ui.config package net.codinux.banking.ui.config
import net.codinux.banking.client.model.BankAccountType
import net.codinux.banking.client.model.tan.ActionRequiringTan import net.codinux.banking.client.model.tan.ActionRequiringTan
import net.codinux.banking.ui.model.TransactionsGrouping import net.codinux.banking.ui.model.TransactionsGrouping
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
object Internationalization { object Internationalization {
@ -11,6 +13,8 @@ object Internationalization {
const val ErrorTransferMoney = "Überweisung konnte nicht ausgeführt werden" const val ErrorTransferMoney = "Überweisung konnte nicht ausgeführt werden"
const val ErrorBiometricAuthentication = "Biometrische Authentifizierung fehlgeschlagen"
fun getTextForActionRequiringTan(action: ActionRequiringTan): String = when (action) { fun getTextForActionRequiringTan(action: ActionRequiringTan): String = when (action) {
ActionRequiringTan.GetAnonymousBankInfo, ActionRequiringTan.GetAnonymousBankInfo,
@ -30,4 +34,23 @@ object Internationalization {
TransactionsGrouping.None -> "Nicht gruppieren" TransactionsGrouping.None -> "Nicht gruppieren"
} }
fun translate(accountType: BankAccountType): String = when (accountType) {
BankAccountType.CheckingAccount -> "Girokonto"
BankAccountType.SavingsAccount -> "Sparkonto"
BankAccountType.FixedTermDepositAccount -> "Festgeldkonto"
BankAccountType.SecuritiesAccount -> "Wertpapierdepot"
BankAccountType.LoanAccount -> "Darlehenskonto"
BankAccountType.CreditCardAccount -> "Kreditkartenkonto"
BankAccountType.FundDeposit -> "Fondsdepot"
BankAccountType.BuildingLoanContract -> "Bausparvertrag"
BankAccountType.InsuranceContract -> "Versicherungsvertrag"
BankAccountType.Other -> "Sonstige"
}
fun translate(authenticationMethod: AppAuthenticationMethod): String = when (authenticationMethod) {
AppAuthenticationMethod.None -> "Ungeschützt"
AppAuthenticationMethod.Password -> "Passwort"
AppAuthenticationMethod.Biometric -> "Biometrie"
}
} }

View File

@ -11,9 +11,10 @@ fun ApplicationErrorDialog(error: ApplicationError, onDismiss: (() -> Unit)? = n
ErroneousAction.AddAccount -> Internationalization.ErrorAddAccount ErroneousAction.AddAccount -> Internationalization.ErrorAddAccount
ErroneousAction.UpdateAccountTransactions -> Internationalization.ErrorUpdateAccountTransactions ErroneousAction.UpdateAccountTransactions -> Internationalization.ErrorUpdateAccountTransactions
ErroneousAction.TransferMoney -> Internationalization.ErrorTransferMoney ErroneousAction.TransferMoney -> Internationalization.ErrorTransferMoney
ErroneousAction.BiometricAuthentication -> Internationalization.ErrorBiometricAuthentication
} }
// add exception stacktrace? // add exception stacktrace?
ErrorDialog(error.errorMessage, title, onDismiss = onDismiss) ErrorDialog(error.errorMessage, title, error.exception, onDismiss = onDismiss)
} }

View File

@ -7,23 +7,31 @@ import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.window.DialogProperties
import net.codinux.banking.ui.composables.text.HeaderText import net.codinux.banking.ui.composables.text.HeaderText
import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.Style import net.codinux.banking.ui.extensions.verticalScroll
@Composable @Composable
fun ErrorDialog( fun ErrorDialog(
text: String, text: String,
title: String? = null, title: String? = null,
exception: Throwable? = null,
confirmButtonText: String = "OK", confirmButtonText: String = "OK",
onDismiss: (() -> Unit)? = null onDismiss: (() -> Unit)? = null
) { ) {
val effectiveText = if (exception == null) text else {
"$text\r\n\r\nFehlermeldung:\r\n${exception.stackTraceToString()}"
}
AlertDialog( AlertDialog(
text = { Text(text) }, text = { Text(effectiveText, Modifier.verticalScroll()) },
title = { title?.let { title = { title?.let {
HeaderText(title, Modifier.fillMaxWidth(), TextAlign.Center) HeaderText(title, Modifier.fillMaxWidth(), TextAlign.Center)
} }, } },
properties = if (exception == null) DialogProperties() else DialogProperties(usePlatformDefaultWidth = false),
onDismissRequest = { onDismiss?.invoke() }, onDismissRequest = { onDismiss?.invoke() },
confirmButton = { confirmButton = {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {

View File

@ -1,5 +1,6 @@
package net.codinux.banking.ui.forms package net.codinux.banking.ui.forms
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.Text import androidx.compose.material.Text
@ -9,18 +10,42 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.config.Colors
@Composable @Composable
fun FormListItem(label: String, isSelectable: Boolean = false, isSelected: Boolean = false, selectedIconContentDescription: String? = null) { fun FormListItem(label: String, itemHeight: Dp = 32.dp, onClick: (() -> Unit)? = null) {
FormListItemImpl(label, itemHeight = itemHeight, onClick = onClick)
}
Row(Modifier.fillMaxWidth().height(32.dp).padding(4.dp), verticalAlignment = Alignment.CenterVertically) { @Composable
fun SelectableFormListItem(
label: String,
isSelected: Boolean = false,
selectedIconContentDescription: String? = null,
itemHeight: Dp = 32.dp,
onClick: (() -> Unit)? = null
) {
FormListItemImpl(label, true, isSelected, selectedIconContentDescription, itemHeight, onClick)
}
@Composable
private fun FormListItemImpl(
label: String,
isSelectable: Boolean = false,
isSelected: Boolean = false,
selectedIconContentDescription: String? = null,
itemHeight: Dp = 32.dp,
onClick: (() -> Unit)? = null
) {
Row(Modifier.fillMaxWidth().height(itemHeight).clickable { onClick?.invoke() }.padding(4.dp), verticalAlignment = Alignment.CenterVertically) {
if (isSelectable) { if (isSelectable) {
Column(Modifier.size(24.dp).padding(end = 8.dp)) { Column(Modifier.padding(end = 8.dp).size(24.dp)) {
if (isSelected) { if (isSelected) {
Icon(Icons.Outlined.Check, selectedIconContentDescription ?: "Item ist ausgewählt") Icon(Icons.Outlined.Check, selectedIconContentDescription ?: "Item ist ausgewählt", tint = Colors.FormListItemTextColor)
} }
} }
} }

View File

@ -17,7 +17,16 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@Composable // try BasicSecureTextField @Composable // try BasicSecureTextField
fun PasswordTextField(password: String = "", label: String = "Passwort", forceHidePassword: Boolean? = null, onEnterPressed: (() -> Unit)? = null, onChange: (String) -> Unit) { fun PasswordTextField(
password: String = "",
label: String = "Passwort",
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions? = null,
isError: Boolean = false,
forceHidePassword: Boolean? = null,
onEnterPressed: (() -> Unit)? = null,
onChange: (String) -> Unit
) {
var passwordVisible by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) }
@ -29,7 +38,8 @@ fun PasswordTextField(password: String = "", label: String = "Passwort", forceHi
value = password, value = password,
onValueChange = { onChange(it) }, onValueChange = { onChange(it) },
label = { Text(label) }, label = { Text(label) },
modifier = Modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
isError = isError,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = { trailingIcon = {
val visibilityIcon = if (passwordVisible) { val visibilityIcon = if (passwordVisible) {
@ -43,7 +53,7 @@ fun PasswordTextField(password: String = "", label: String = "Passwort", forceHi
modifier = Modifier.size(24.dp).clickable { passwordVisible = !passwordVisible } modifier = Modifier.size(24.dp).clickable { passwordVisible = !passwordVisible }
) )
}, },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), keyboardOptions = keyboardOptions?.copy(keyboardType = KeyboardType.Password) ?: KeyboardOptions(keyboardType = KeyboardType.Password),
onEnterPressed = onEnterPressed onEnterPressed = onEnterPressed
) )
} }

View File

@ -0,0 +1,68 @@
package net.codinux.banking.ui.forms
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import net.codinux.banking.ui.config.Colors
@Composable
fun <T> SegmentedControl(
options: Collection<T>,
selectedOption: T,
modifier: Modifier = Modifier,
color: Color = Colors.Accent,
cornerSize: Dp = 8.dp,
getOptionDisplayText: ((T) -> String)? = null,
onOptionSelected: (T) -> Unit
) {
Row(horizontalArrangement = Arrangement.Center) {
Row(modifier.height(48.dp).border(2.dp, color, RoundedCornerShape(cornerSize))) {
options.forEachIndexed { index, option ->
val isSelected = option == selectedOption
val backgroundColor = if (isSelected) color else Color.Transparent
val textColor = if (isSelected) Color.White else color
Box(
modifier = Modifier
.clickable { onOptionSelected(option) }
.fillMaxHeight()
.weight(1f)
.let {
if (index == 0) {
it.background(backgroundColor, RoundedCornerShape(topStart = cornerSize, bottomStart = cornerSize))
} else if (index == options.size - 1) {
it.background(backgroundColor, RoundedCornerShape(topEnd = cornerSize, bottomEnd = cornerSize))
} else {
it.background(backgroundColor)
}
}
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = getOptionDisplayText?.invoke(option) ?: option.toString(),
color = textColor,
textAlign = TextAlign.Center
)
}
if (index < options.size - 1) {
Divider(Modifier.fillMaxHeight().width(1.dp), color = color)
}
}
}
}
}

View File

@ -0,0 +1,17 @@
package net.codinux.banking.ui.model
class AuthenticationResult(
val successful: Boolean,
val error: String? = null
) {
override fun toString(): String {
return if (successful) {
"Successful"
}
else {
"Error occurred: $error"
}
}
}

View File

@ -5,5 +5,7 @@ enum class ErroneousAction {
UpdateAccountTransactions, UpdateAccountTransactions,
TransferMoney TransferMoney,
BiometricAuthentication
} }

View File

@ -0,0 +1,78 @@
package net.codinux.banking.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.ui.config.Internationalization
import net.codinux.banking.ui.extensions.verticalScroll
import net.codinux.banking.ui.forms.*
@Composable
fun BankAccountSettingsScreen(account: BankAccountEntity, onClosed: () -> Unit) {
var enteredAccountName by remember { mutableStateOf(account.displayName) }
var selectedIncludeInAutomaticAccountsUpdate by remember { mutableStateOf(account.includeInAutomaticAccountsUpdate) }
var selectedHideAccount by remember { mutableStateOf(account.hideAccount) }
val hasDataChanged by remember(enteredAccountName) {
mutableStateOf(
enteredAccountName != account.displayName
|| selectedIncludeInAutomaticAccountsUpdate != account.includeInAutomaticAccountsUpdate
|| selectedHideAccount != account.hideAccount
)
}
FullscreenViewBase(account.displayName, onClosed = onClosed) {
Column(Modifier.fillMaxSize().verticalScroll().padding(8.dp)) {
Column {
SectionHeader("Einstellungen", false)
OutlinedTextField(
label = { Text("Name") },
value = enteredAccountName,
onValueChange = { enteredAccountName = it },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 8.dp)
)
// BooleanOption("Bei Kontoaktualisierung einbeziehen", selectedIncludeInAutomaticAccountsUpdate) { selectedIncludeInAutomaticAccountsUpdate = it }
//
// BooleanOption("Konto ausblenden", selectedHideAccount) { selectedHideAccount = it }
}
SelectionContainer {
Column {
SectionHeader("Kontodaten") // TODO: add a share icon to copy data
LabelledValue("Kontoinhaber", account.accountHolderName)
LabelledValue("Kontonummer", account.identifier)
LabelledValue("Unterkontenmerkmal", account.subAccountNumber)
LabelledValue("IBAN", account.iban ?: "")
LabelledValue("Typ", Internationalization.translate(account.type))
}
}
Column {
SectionHeader("Unterstützt")
Column(Modifier.padding(top = 8.dp)) {
SelectableFormListItem("Kontostand abrufen", account.supportsBalanceRetrieval, "Unterstützt das Abrufen des Kontostandes")
SelectableFormListItem("Kontoumsätze abrufen", account.supportsTransactionRetrieval, "Unterstützt das Abrufen der Kontoumsätze")
SelectableFormListItem("Überweisen", account.supportsMoneyTransfer, "Unterstützt Überweisungen")
SelectableFormListItem("Echtzeitüberweisung", account.supportsInstantTransfer, "Unterstützt Echtzeitüberweisungen")
}
}
}
}
}

View File

@ -6,6 +6,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.codinux.banking.dataaccess.entities.BankAccessEntity import net.codinux.banking.dataaccess.entities.BankAccessEntity
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.extensions.verticalScroll import net.codinux.banking.ui.extensions.verticalScroll
import net.codinux.banking.ui.forms.* import net.codinux.banking.ui.forms.*
@ -56,7 +57,9 @@ fun BankSettingsScreen(bank: BankAccessEntity, onClosed: () -> Unit) {
Column(Modifier.padding(top = 8.dp)) { Column(Modifier.padding(top = 8.dp)) {
bank.accounts.sortedBy { it.displayIndex }.forEach { account -> bank.accounts.sortedBy { it.displayIndex }.forEach { account ->
FormListItem(account.displayName) FormListItem(account.displayName, itemHeight = 42.dp) {
DI.uiState.showBankAccountSettingsScreenForAccount.value = account
}
} }
} }
} }
@ -78,7 +81,7 @@ fun BankSettingsScreen(bank: BankAccessEntity, onClosed: () -> Unit) {
Column(Modifier.padding(top = 8.dp)) { Column(Modifier.padding(top = 8.dp)) {
bank.tanMethods.sortedBy { it.identifier }.forEach { tanMethod -> bank.tanMethods.sortedBy { it.identifier }.forEach { tanMethod ->
FormListItem(tanMethod.displayName, true, tanMethod == bank.selectedTanMethod, "TAN Verfahren ist ausgewähltes TAN Verfahren") SelectableFormListItem(tanMethod.displayName, tanMethod == bank.selectedTanMethod, "TAN Verfahren ist ausgewähltes TAN Verfahren")
} }
} }
} }
@ -89,7 +92,7 @@ fun BankSettingsScreen(bank: BankAccessEntity, onClosed: () -> Unit) {
Column(Modifier.padding(top = 8.dp)) { Column(Modifier.padding(top = 8.dp)) {
bank.tanMedia.sortedBy { it.status }.forEach { tanMedium -> bank.tanMedia.sortedBy { it.status }.forEach { tanMedium ->
FormListItem(tanMedium.displayName, true, tanMedium == bank.selectedTanMedium, "TAN Medium ist ausgewähltes TAN Medium") SelectableFormListItem(tanMedium.displayName, tanMedium == bank.selectedTanMedium, "TAN Medium ist ausgewähltes TAN Medium")
} }
} }
} }

View File

@ -16,12 +16,14 @@ import androidx.compose.ui.zIndex
import net.codinux.banking.ui.composables.text.HeaderText import net.codinux.banking.ui.composables.text.HeaderText
import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.config.Style
@Composable @Composable
fun FullscreenViewBase( fun FullscreenViewBase(
title: String, title: String,
confirmButtonTitle: String = "OK", confirmButtonTitle: String = "OK",
confirmButtonEnabled: Boolean = true, confirmButtonEnabled: Boolean = true,
showButtonBar: Boolean = true,
onClosed: () -> Unit, onClosed: () -> Unit,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
@ -32,7 +34,7 @@ fun FullscreenViewBase(
Column(Modifier.fillMaxSize().zIndex(1000f).background(Color.White).padding(8.dp)) { Column(Modifier.fillMaxSize().zIndex(1000f).background(Color.White).padding(8.dp)) {
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
HeaderText(title, Modifier.padding(top = 8.dp, bottom = 16.dp).weight(1f)) HeaderText(title, Modifier.padding(top = 8.dp, bottom = 16.dp).weight(1f), textColor = Style.ListItemHeaderTextColor)
if (DI.platform.isDesktop) { if (DI.platform.isDesktop) {
TextButton(onClosed, colors = ButtonDefaults.buttonColors(contentColor = Colors.Zinc700, backgroundColor = Color.Transparent)) { TextButton(onClosed, colors = ButtonDefaults.buttonColors(contentColor = Colors.Zinc700, backgroundColor = Color.Transparent)) {
@ -45,19 +47,21 @@ fun FullscreenViewBase(
content() content()
} }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { if (showButtonBar) {
// TextButton(onClick = onClosed, Modifier.width(Style.DialogButtonWidth)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
// Text("Abbrechen", color = Colors.CodinuxSecondaryColor) // TextButton(onClick = onClosed, Modifier.width(Style.DialogButtonWidth)) {
// } // Text("Abbrechen", color = Colors.CodinuxSecondaryColor)
// }
// //
// Spacer(Modifier.width(8.dp)) // Spacer(Modifier.width(8.dp))
TextButton( TextButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = confirmButtonEnabled, enabled = confirmButtonEnabled,
onClick = { /* onConfirm?.invoke() ?: */ onClosed() } onClick = { /* onConfirm?.invoke() ?: */ onClosed() }
) { ) {
Text(confirmButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center) Text(confirmButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center)
}
} }
} }
} }

View File

@ -0,0 +1,115 @@
package net.codinux.banking.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import bankmeister.composeapp.generated.resources.*
import bankmeister.composeapp.generated.resources.Res
import net.codinux.banking.ui.composables.authentification.BiometricAuthenticationButton
import net.codinux.banking.ui.forms.PasswordTextField
import net.codinux.banking.ui.model.AuthenticationResult
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
import net.codinux.banking.ui.model.settings.AppSettings
import net.codinux.banking.ui.service.AuthenticationService
import net.codinux.banking.ui.service.safelyAuthenticateWithBiometrics
import org.jetbrains.compose.resources.imageResource
@Composable
fun LoginScreen(appSettings: AppSettings, onLoginSuccess: () -> Unit) {
var password by remember { mutableStateOf("") }
var showError by remember { mutableStateOf(false) }
fun successfullyLoggedIn() {
onLoginSuccess()
}
fun checkPassword() {
if (appSettings.hashedPassword != null && AuthenticationService.checkPassword(password, appSettings.hashedPassword!!)) {
successfullyLoggedIn()
} else {
showError = true
}
}
fun checkBiometricLoginResult(result: AuthenticationResult) {
if (result.successful) {
successfullyLoggedIn()
} else {
showError = true
}
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Image(imageResource(Res.drawable.AppIcon_round), "Bankmeister's app icon", Modifier.size(144.dp).padding(bottom = 32.dp))
if (appSettings.authenticationMethod == AppAuthenticationMethod.Password) {
Text("Bitte geben Sie Ihr Passwort ein um die App zu entsperren", style = MaterialTheme.typography.h5, textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(24.dp))
PasswordTextField(
password = password,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
onEnterPressed = { checkPassword() },
isError = showError
) {
password = it
showError = false
}
if (showError) {
Spacer(modifier = Modifier.height(16.dp))
Text("Passwort ist falsch", color = MaterialTheme.colors.error, fontSize = 18.sp)
}
Button(modifier = Modifier.padding(top = 24.dp).width(300.dp).height(50.dp), onClick = { checkPassword() }) {
Text("Login", color = Color.White)
}
}
if (appSettings.authenticationMethod == AppAuthenticationMethod.Biometric) {
if (showError) {
Text("Biometrische Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.", color = MaterialTheme.colors.error, fontSize = 18.sp,
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).padding(horizontal = 16.dp))
}
BiometricAuthenticationButton {
checkBiometricLoginResult(it)
}
}
}
}
LaunchedEffect(appSettings.authenticationMethod) {
if (appSettings.authenticationMethod == AppAuthenticationMethod.Biometric) {
AuthenticationService.safelyAuthenticateWithBiometrics { result ->
checkBiometricLoginResult(result)
}
}
}
}

View File

@ -0,0 +1,125 @@
package net.codinux.banking.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import net.codinux.banking.ui.composables.authentification.BiometricAuthenticationButton
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.config.Internationalization
import net.codinux.banking.ui.extensions.verticalScroll
import net.codinux.banking.ui.forms.PasswordTextField
import net.codinux.banking.ui.forms.SegmentedControl
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
import net.codinux.banking.ui.model.settings.AppSettings
import net.codinux.banking.ui.service.AuthenticationService
private val buttonHeight = 50.dp
@Composable
fun ProtectAppSettingsDialog(appSettings: AppSettings, onClosed: () -> Unit) {
val currentAuthenticationMethod = appSettings.authenticationMethod
val isBiometricAuthenticationSupported = AuthenticationService.supportsBiometricAuthentication
val supportedAuthenticationMethods = buildList {
add(AppAuthenticationMethod.Password)
if (isBiometricAuthenticationSupported) {
add(AppAuthenticationMethod.Biometric)
}
add(AppAuthenticationMethod.None)
}
var selectedAuthenticationMethod by remember { mutableStateOf(if (appSettings.authenticationMethod == AppAuthenticationMethod.None) AppAuthenticationMethod.Password else appSettings.authenticationMethod) }
var newPassword by remember { mutableStateOf("") }
var confirmedNewPassword by remember { mutableStateOf("") }
var hasAuthenticatedWithBiometric by remember { mutableStateOf(false) }
val isRequiredDataEntered by remember(newPassword, confirmedNewPassword) {
derivedStateOf {
(selectedAuthenticationMethod == AppAuthenticationMethod.Password && newPassword.isNotBlank() && newPassword == confirmedNewPassword)
|| (selectedAuthenticationMethod == AppAuthenticationMethod.Biometric && hasAuthenticatedWithBiometric)
}
}
val coroutineScope = rememberCoroutineScope()
fun saveNewAppProtection() {
coroutineScope.launch {
appSettings.authenticationMethod = selectedAuthenticationMethod
appSettings.hashedPassword = if (selectedAuthenticationMethod == AppAuthenticationMethod.Password) AuthenticationService.hashPassword(newPassword)
else null
DI.bankingService.saveAppSettings(appSettings)
onClosed()
}
}
FullscreenViewBase("Appzugang schützen", showButtonBar = false, onClosed = onClosed) {
Column(Modifier.fillMaxSize().padding(8.dp)) {
SegmentedControl(supportedAuthenticationMethods, selectedAuthenticationMethod, Modifier.padding(bottom = 20.dp), getOptionDisplayText = { Internationalization.translate(it) }) {
selectedAuthenticationMethod = it
}
Column(Modifier.weight(1f).verticalScroll()) {
Spacer(Modifier.weight(1f))
if (selectedAuthenticationMethod == AppAuthenticationMethod.None) {
Row(Modifier.fillMaxWidth()) {
if (currentAuthenticationMethod == AppAuthenticationMethod.None) {
Text("Appzugangsschutz ist bereits ungeschützt", fontSize = 18.sp, textAlign = TextAlign.Center)
} else {
Text("Möchten Sie den Appzugangsschutz wirklich entfernen?", fontSize = 18.sp, textAlign = TextAlign.Center)
}
}
}
if (selectedAuthenticationMethod == AppAuthenticationMethod.Password) {
PasswordTextField(newPassword, "Neues Password", keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)) { newPassword = it }
PasswordTextField(confirmedNewPassword, "Password bestätigen", Modifier.padding(top = 16.dp), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)) { confirmedNewPassword = it }
}
if (selectedAuthenticationMethod == AppAuthenticationMethod.Biometric) {
BiometricAuthenticationButton { result ->
hasAuthenticatedWithBiometric = result.successful
}
}
Spacer(Modifier.weight(1f))
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
if (selectedAuthenticationMethod == AppAuthenticationMethod.None) {
Button(modifier = Modifier.fillMaxWidth().height(buttonHeight), enabled = currentAuthenticationMethod != AppAuthenticationMethod.None,
colors = ButtonDefaults.buttonColors(Colors.DestructiveColor), onClick = { saveNewAppProtection() }) {
Text("Appzugangsschutz entfernen", color = Color.White)
}
} else {
Button(modifier = Modifier.fillMaxWidth().height(buttonHeight), enabled = isRequiredDataEntered,
colors = ButtonDefaults.buttonColors(Colors.Accent), onClick = { saveNewAppProtection() }) {
Text("Setzen", color = Color.White)
}
}
}
}
}
}

View File

@ -25,7 +25,7 @@ class AccountTransactionsFilterService {
appliedAccountFilter = appliedAccountFilter.filter { it.valueDate.year == year && (month == null || it.valueDate.monthNumber == month) } appliedAccountFilter = appliedAccountFilter.filter { it.valueDate.year == year && (month == null || it.valueDate.monthNumber == month) }
} }
val searchTerms = filter.searchTerm.split(SearchTermOrSeparatorSymbol).filter { it.isNotBlank() } val searchTerms = filter.searchTerm.split(SearchTermOrSeparatorSymbol).filter { it.isNotBlank() }.map { it.trim() }
return if (searchTerms.isEmpty()) { return if (searchTerms.isEmpty()) {
appliedAccountFilter appliedAccountFilter
} else { } else {

View File

@ -0,0 +1,29 @@
package net.codinux.banking.ui.service
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.model.AuthenticationResult
import net.codinux.banking.ui.model.error.ErroneousAction
import net.codinux.log.Log
expect object AuthenticationService {
fun hashPassword(password: String): String
fun checkPassword(password: String, hashedPassword: String): Boolean
val supportsBiometricAuthentication: Boolean
fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit)
}
fun AuthenticationService.safelyAuthenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit) {
try {
this.authenticateWithBiometrics(authenticationResult)
} catch (e: Throwable) {
Log.error(e) { "Could not authenticate with Biometrics" }
DI.uiState.applicationErrorOccurred(ErroneousAction.BiometricAuthentication, "Beim Login mit Fingerabdruck / Gesichtsscan ist etwas fehlgeschlagen, was nicht fehlschlagen sollte. Die folgende Fehlermeldung wird Ihnen vermutlich nichts sagen. Am besten informieren Sie die Entwickler (die faulen Säcke), z. B. mit einem Screenshot dieser Fehlermeldung, damit diese sich darum kümmern.", e)
}
}

View File

@ -9,6 +9,7 @@ import net.codinux.banking.client.model.tan.EnterTanResult
import net.codinux.banking.client.model.tan.TanChallenge import net.codinux.banking.client.model.tan.TanChallenge
import net.codinux.banking.dataaccess.entities.HoldingEntity import net.codinux.banking.dataaccess.entities.HoldingEntity
import net.codinux.banking.dataaccess.entities.BankAccessEntity import net.codinux.banking.dataaccess.entities.BankAccessEntity
import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.ui.model.* import net.codinux.banking.ui.model.*
import net.codinux.banking.ui.model.error.ApplicationError import net.codinux.banking.ui.model.error.ApplicationError
import net.codinux.banking.ui.model.error.BankingClientError import net.codinux.banking.ui.model.error.BankingClientError
@ -58,8 +59,12 @@ class UiState : ViewModel() {
val showBankSettingsScreenForBank = MutableStateFlow<BankAccessEntity?>(null) val showBankSettingsScreenForBank = MutableStateFlow<BankAccessEntity?>(null)
val showBankAccountSettingsScreenForAccount = MutableStateFlow<BankAccountEntity?>(null)
val showExportScreen = MutableStateFlow(false) val showExportScreen = MutableStateFlow(false)
val showProtectAppSettingsScreen = MutableStateFlow(false)
val tanChallengeReceived = MutableStateFlow<TanChallengeReceived?>(null) val tanChallengeReceived = MutableStateFlow<TanChallengeReceived?>(null)

View File

@ -0,0 +1,35 @@
package net.codinux.banking.ui.service
import kotlinx.datetime.*
import net.codinux.banking.client.model.Amount
import net.codinux.banking.client.model.DefaultValues
import net.codinux.banking.client.model.extensions.EuropeBerlin
import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.AccountTransactionsFilter
import kotlin.test.Test
import kotlin.test.assertEquals
class AccountTransactionsFilterServiceTest {
private val underTest = AccountTransactionsFilterService()
@Test
fun filterAccounts_TermsSeparatedByComma_WhitespaceGetsFilteredOut() {
// before whitespace after comma led to that for " rewe" was search which yielded no results (as opposed to "rewe") -> ensure whitespace gets remove
val filter = filter("edeka, rewe")
val result = underTest.filterAccounts(listOf(transaction("Edeka"), transaction("Rewe")), filter)
assertEquals(2, result.size)
}
private fun filter(searchTerm: String = "") = AccountTransactionsFilter().apply {
this.updateSearchTerm(searchTerm)
}
private fun transaction(reference: String? = null, otherPartyName: String? = null, valueDate: LocalDate = Clock.System.todayIn(TimeZone.EuropeBerlin)) = AccountTransactionViewModel(
-1, -1, -1, Amount("0"), DefaultValues.DefaultCurrency, reference, valueDate, otherPartyName
)
}

View File

@ -0,0 +1,21 @@
package net.codinux.banking.ui.service
import at.favre.lib.crypto.bcrypt.BCrypt
import net.codinux.banking.ui.model.AuthenticationResult
actual object AuthenticationService {
actual fun hashPassword(password: String): String =
BCrypt.withDefaults().hashToString(12, password.toCharArray())
actual fun checkPassword(password: String, hashedPassword: String): Boolean =
BCrypt.verifyer().verify(password.toCharArray(), hashedPassword).verified
actual val supportsBiometricAuthentication = false
actual fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit) {
authenticationResult(AuthenticationResult(false, "Biometrics is not supported"))
}
}

View File

@ -0,0 +1,26 @@
package net.codinux.banking.ui.service
import net.codinux.banking.ui.model.AuthenticationResult
actual object AuthenticationService {
// for iOS see e.g.
// https://medium.com/@mohamed.ma872/strengthening-mobile-app-security-pbkdf2-bcrypt-and-scrypt-for-android-and-ios-8089b0edbf76
// https://github.com/felipeflorencio/BCryptSwift
actual fun hashPassword(password: String): String {
return password // TODO
}
actual fun checkPassword(password: String, hashedPassword: String): Boolean {
return password == hashedPassword // TODO
}
actual val supportsBiometricAuthentication = false // TODO
actual fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit) {
authenticationResult(AuthenticationResult(false, "Biometrics is not implemented yet"))
}
}

View File

@ -0,0 +1,22 @@
package net.codinux.banking.ui.service
import net.codinux.banking.ui.model.AuthenticationResult
actual object AuthenticationService {
actual fun hashPassword(password: String): String {
return password // TODO
}
actual fun checkPassword(password: String, hashedPassword: String): Boolean {
return password == hashedPassword // TODO
}
actual val supportsBiometricAuthentication = false
actual fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit) {
authenticationResult(AuthenticationResult(false, "Biometrics is not supported"))
}
}

View File

@ -7,6 +7,8 @@ banking-client = "0.6.0"
kcsv = "2.2.0" kcsv = "2.2.0"
kotlinx-serializable = "1.7.1" kotlinx-serializable = "1.7.1"
favre-bcrypt = "0.10.2"
klf = "1.6.1" klf = "1.6.1"
logback = "1.5.7" logback = "1.5.7"
@ -23,6 +25,7 @@ androidx-core-ktx = "1.13.1"
androidx-espresso-core = "3.6.1" androidx-espresso-core = "3.6.1"
androidx-lifecycle = "2.8.0" androidx-lifecycle = "2.8.0"
androidx-material = "1.12.0" androidx-material = "1.12.0"
androidx-biometric = "1.1.0"
androidx-test-junit = "1.2.1" androidx-test-junit = "1.2.1"
compose-plugin = "1.6.11" compose-plugin = "1.6.11"
@ -36,6 +39,8 @@ kcsv = { group = "net.codinux.csv", name = "kcsv", version.ref = "kcsv" }
coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-serializable = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serializable" } kotlinx-serializable = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serializable" }
favre-bcrypt = { group = "at.favre.lib", name = "bcrypt", version.ref = "favre-bcrypt" }
klf = { group = "net.codinux.log", name = "klf", version.ref = "klf" } klf = { group = "net.codinux.log", name = "klf", version.ref = "klf" }
logback = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } logback = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" }
@ -55,6 +60,7 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "androidx-biometric" }
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }