taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

SettingsScreen.kt (19499B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2026 Taler Systems S.A.
      4  *
      5  * GNU Taler is free software; you can redistribute it and/or modify it under the
      6  * terms of the GNU General Public License as published by the Free Software
      7  * Foundation; either version 3, or (at your option) any later version.
      8  *
      9  * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
     10  * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11  * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
     12  *
     13  * You should have received a copy of the GNU General Public License along with
     14  * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15  */
     16 
     17 package net.taler.wallet.settings
     18 
     19 import android.app.Activity.RESULT_OK
     20 import android.app.KeyguardManager
     21 import android.content.Context
     22 import android.content.Context.KEYGUARD_SERVICE
     23 import android.content.Intent
     24 import android.os.Build
     25 import android.provider.Settings.ACTION_BIOMETRIC_ENROLL
     26 import android.provider.Settings.ACTION_FINGERPRINT_ENROLL
     27 import android.provider.Settings.ACTION_SECURITY_SETTINGS
     28 import android.provider.Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED
     29 import android.widget.Toast
     30 import androidx.activity.compose.rememberLauncherForActivityResult
     31 import androidx.activity.result.contract.ActivityResultContracts
     32 import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
     33 import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
     34 import androidx.biometric.BiometricManager
     35 import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
     36 import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
     37 import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED
     38 import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS
     39 import androidx.compose.foundation.clickable
     40 import androidx.compose.foundation.layout.Column
     41 import androidx.compose.foundation.layout.PaddingValues
     42 import androidx.compose.foundation.layout.Row
     43 import androidx.compose.foundation.layout.fillMaxSize
     44 import androidx.compose.foundation.layout.fillMaxWidth
     45 import androidx.compose.foundation.layout.padding
     46 import androidx.compose.foundation.layout.size
     47 import androidx.compose.foundation.rememberScrollState
     48 import androidx.compose.foundation.verticalScroll
     49 import androidx.compose.material.icons.Icons
     50 import androidx.compose.material.icons.filled.AccountBalance
     51 import androidx.compose.material.icons.filled.Dns
     52 import androidx.compose.material.icons.filled.DomainAdd
     53 import androidx.compose.material.icons.filled.LocalAtm
     54 import androidx.compose.material.icons.filled.VideoLibrary
     55 import androidx.compose.material3.ExperimentalMaterial3Api
     56 import androidx.compose.material3.HorizontalDivider
     57 import androidx.compose.material3.Icon
     58 import androidx.compose.material3.MaterialTheme
     59 import androidx.compose.material3.SnackbarHostState
     60 import androidx.compose.material3.Switch
     61 import androidx.compose.material3.Text
     62 import androidx.compose.runtime.Composable
     63 import androidx.compose.runtime.collectAsState
     64 import androidx.compose.runtime.getValue
     65 import androidx.compose.runtime.rememberCoroutineScope
     66 import androidx.compose.ui.Alignment
     67 import androidx.compose.ui.Modifier
     68 import androidx.compose.ui.graphics.vector.ImageVector
     69 import androidx.compose.ui.platform.LocalContext
     70 import androidx.compose.ui.res.stringResource
     71 import androidx.compose.ui.res.vectorResource
     72 import androidx.compose.ui.unit.dp
     73 import com.google.android.material.dialog.MaterialAlertDialogBuilder
     74 import kotlinx.coroutines.launch
     75 import net.taler.wallet.BuildConfig
     76 import net.taler.wallet.NavigateCallback
     77 import net.taler.wallet.R
     78 import net.taler.wallet.WalletDestination
     79 import net.taler.wallet.backend.TalerErrorInfo
     80 import net.taler.wallet.main.MainViewModel
     81 import net.taler.wallet.withdraw.TestWithdrawStatus
     82 
     83 @OptIn(ExperimentalMaterial3Api::class)
     84 @Composable
     85 fun SettingsScreen(
     86     model: MainViewModel,
     87     innerPadding: PaddingValues,
     88     snackbarHostState: SnackbarHostState,
     89     onNavigate: NavigateCallback,
     90     onShowError: (TalerErrorInfo) -> Unit,
     91 ) {
     92     val context = LocalContext.current
     93     val dbExportMessage = stringResource(R.string.settings_db_export_message)
     94     val dbImportMessage = stringResource(R.string.settings_db_import_message)
     95     val testWithdrawalMessage = stringResource(R.string.settings_test_withdrawal)
     96     val importCanceledMessage = stringResource(R.string.settings_alert_import_canceled)
     97     val testRunningMessage = stringResource(R.string.settings_test_running)
     98     val resetDoneMessage = stringResource(R.string.settings_alert_reset_done)
     99     val resetCanceledMessage = stringResource(R.string.settings_alert_reset_canceled)
    100     val biometricAuthUnavailableMessage = stringResource(R.string.biometric_auth_unavailable)
    101     val scope = rememberCoroutineScope()
    102     val settingsManager = model.settingsManager
    103     val withdrawManager = model.withdrawManager
    104 
    105     val biometricLockEnabled by settingsManager.getBiometricLockEnabled(context).collectAsState(false)
    106     val devModeEnabled by settingsManager.getDevModeEnabled(context).collectAsState(false)
    107     val withdrawTestStatus by withdrawManager.withdrawTestStatus.collectAsState()
    108 
    109     val walletVersion = model.walletVersion
    110     val walletVersionHash = model.walletVersionHash?.take(7)
    111     val exchangeVersion = model.exchangeVersion
    112     val merchantVersion = model.merchantVersion
    113 
    114     val logLauncher = rememberLauncherForActivityResult(CreateDocument("text/plain")) { uri ->
    115         uri?.let { settingsManager.exportLogcat(it) }
    116     }
    117     val dbExportLauncher = rememberLauncherForActivityResult(CreateDocument("application/json")) { uri ->
    118         uri?.let {
    119             scope.launch { snackbarHostState.showSnackbar(dbExportMessage) }
    120             settingsManager.exportDb(it)
    121         }
    122     }
    123     val dbImportLauncher = rememberLauncherForActivityResult(OpenDocument()) { uri ->
    124         uri?.let {
    125             scope.launch { snackbarHostState.showSnackbar(dbImportMessage) }
    126             onNavigate(WalletDestination.Main, true)
    127             settingsManager.importDb(it)
    128         }
    129     }
    130 
    131     val biometricEnrollLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
    132         if (result.resultCode == RESULT_OK) {
    133             enableBiometrics(context, settingsManager, false, biometricAuthUnavailableMessage) {}
    134         }
    135     }
    136 
    137     Column(
    138         modifier = Modifier
    139             .fillMaxSize()
    140             .padding(innerPadding)
    141             .verticalScroll(rememberScrollState())
    142     ) {
    143         SettingsItem(
    144             title = stringResource(R.string.exchange_settings_title),
    145             summary = stringResource(R.string.exchange_settings_summary),
    146             icon = Icons.Default.Dns,
    147             onClick = { onNavigate(WalletDestination.ExchangeList, false) }
    148         )
    149 
    150         SettingsItem(
    151             title = stringResource(R.string.settings_bank_accounts),
    152             summary = stringResource(R.string.settings_bank_accounts_summary),
    153             icon = Icons.Default.AccountBalance,
    154             onClick = { onNavigate(WalletDestination.BankAccounts(), false) }
    155         )
    156 
    157         SettingsItem(
    158             title = stringResource(R.string.settings_donau),
    159             summary = stringResource(R.string.settings_donau_summary),
    160             icon = ImageVector.vectorResource(R.drawable.ic_donau),
    161             onClick = { onNavigate(WalletDestination.SetDonau(), false) }
    162         )
    163 
    164         SettingsSwitchItem(
    165             title = stringResource(R.string.settings_lock_auth),
    166             summary = stringResource(R.string.settings_lock_auth_summary),
    167             icon = ImageVector.vectorResource(R.drawable.ic_shield),
    168             checked = biometricLockEnabled,
    169             onCheckedChange = { enabled ->
    170                 if (enabled) {
    171                     enableBiometrics(context, settingsManager, true, biometricAuthUnavailableMessage) {
    172                         biometricEnrollLauncher.launch(it)
    173                     }
    174                 } else {
    175                     settingsManager.setBiometricLockEnabled(context, false)
    176                 }
    177             }
    178         )
    179 
    180         SettingsSwitchItem(
    181             title = stringResource(R.string.settings_dev_mode),
    182             summary = stringResource(R.string.settings_dev_mode_summary),
    183             icon = ImageVector.vectorResource(R.drawable.ic_developer_mode),
    184             checked = devModeEnabled,
    185             onCheckedChange = { enabled ->
    186                 settingsManager.setDevModeEnabled(context, enabled)
    187             }
    188         )
    189 
    190         if (devModeEnabled) {
    191             HorizontalDivider()
    192 
    193             SettingsItem(
    194                 title = stringResource(R.string.exchange_list_add_dev),
    195                 icon = Icons.Default.DomainAdd,
    196                 onClick = {
    197                     model.exchangeManager.addDevExchanges()
    198                     onNavigate(WalletDestination.ExchangeList, false)
    199                 }
    200             )
    201 
    202             SettingsItem(
    203                 title = stringResource(R.string.settings_withdraw_testkudos),
    204                 summary = stringResource(R.string.settings_withdraw_testkudos_summary),
    205                 icon = Icons.Default.LocalAtm,
    206                 enabled = withdrawTestStatus !is TestWithdrawStatus.Withdrawing,
    207                 onClick = {
    208                     withdrawManager.withdrawTestBalance()
    209                     scope.launch { snackbarHostState.showSnackbar(testWithdrawalMessage) }
    210                     onNavigate(WalletDestination.Main, true)
    211                 }
    212             )
    213 
    214             SettingsItem(
    215                 title = stringResource(R.string.settings_logcat),
    216                 summary = stringResource(R.string.settings_logcat_summary),
    217                 icon = ImageVector.vectorResource(R.drawable.ic_bug_report),
    218                 onClick = { logLauncher.launch("taler-wallet-log-${System.currentTimeMillis()}.txt") }
    219             )
    220 
    221             SettingsItem(
    222                 title = stringResource(R.string.settings_stats),
    223                 summary = stringResource(R.string.settings_stats_summary),
    224                 icon = ImageVector.vectorResource(R.drawable.ic_stats),
    225                 onClick = { onNavigate(WalletDestination.PerformanceStats, false) }
    226             )
    227 
    228             SettingsItem(
    229                 title = stringResource(R.string.settings_db_export),
    230                 summary = stringResource(R.string.settings_db_export_summary),
    231                 icon = ImageVector.vectorResource(R.drawable.ic_unarchive),
    232                 onClick = { dbExportLauncher.launch("taler-wallet-db-${System.currentTimeMillis()}.json") }
    233             )
    234 
    235             SettingsItem(
    236                 title = stringResource(R.string.settings_db_import),
    237                 summary = stringResource(R.string.settings_db_import_summary),
    238                 icon = ImageVector.vectorResource(R.drawable.ic_archive),
    239                 onClick = {
    240                     MaterialAlertDialogBuilder(context)
    241                         .setMessage(R.string.settings_dialog_import_message)
    242                         .setNegativeButton(R.string.import_db) { _, _ ->
    243                             dbImportLauncher.launch(arrayOf("application/json"))
    244                         }
    245                         .setPositiveButton(R.string.cancel) { _, _ ->
    246                             scope.launch { snackbarHostState.showSnackbar(importCanceledMessage) }
    247                         }
    248                         .show()
    249                 }
    250             )
    251 
    252             SettingsItem(
    253                 title = stringResource(R.string.settings_version_app),
    254                 summary = "${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR} ${BuildConfig.VERSION_CODE})",
    255                 icon = ImageVector.vectorResource(R.drawable.ic_account_balance_wallet),
    256             )
    257 
    258             SettingsItem(
    259                 title = stringResource(R.string.settings_version_core),
    260                 summary = "$walletVersion ($walletVersionHash)",
    261                 icon = ImageVector.vectorResource(R.drawable.ic_adjust),
    262             )
    263 
    264             if (exchangeVersion != null) {
    265                 SettingsItem(
    266                     title = stringResource(R.string.settings_version_protocol_exchange),
    267                     summary = exchangeVersion,
    268                     icon = ImageVector.vectorResource(R.drawable.ic_account_balance),
    269                 )
    270             }
    271 
    272             if (merchantVersion != null) {
    273                 SettingsItem(
    274                     title = stringResource(R.string.settings_version_protocol_merchant),
    275                     summary = merchantVersion,
    276                     icon = ImageVector.vectorResource(R.drawable.ic_store_mall),
    277                 )
    278             }
    279 
    280             SettingsItem(
    281                 title = stringResource(R.string.settings_test),
    282                 summary = stringResource(R.string.settings_test_summary),
    283                 icon = Icons.Default.VideoLibrary,
    284                 onClick = {
    285                     settingsManager.runIntegrationTest { onShowError(it) }
    286                     scope.launch { snackbarHostState.showSnackbar(testRunningMessage) }
    287                     onNavigate(WalletDestination.Main, true)
    288                 }
    289             )
    290 
    291             SettingsItem(
    292                 title = stringResource(R.string.settings_reset),
    293                 summary = stringResource(R.string.settings_reset_summary),
    294                 icon = ImageVector.vectorResource(R.drawable.ic_nuke),
    295                 onClick = {
    296                     MaterialAlertDialogBuilder(context)
    297                         .setMessage(R.string.settings_dialog_reset_message)
    298                         .setNegativeButton(R.string.reset) { _, _ ->
    299                             settingsManager.clearDb {
    300                                 model.dangerouslyReset()
    301                             }
    302                             scope.launch { snackbarHostState.showSnackbar(resetDoneMessage) }
    303                         }
    304                         .setPositiveButton(R.string.cancel) { _, _ ->
    305                             scope.launch { snackbarHostState.showSnackbar(resetCanceledMessage) }
    306                         }
    307                         .show()
    308                 }
    309             )
    310         }
    311     }
    312 }
    313 
    314 @Composable
    315 fun SettingsItem(
    316     title: String,
    317     summary: String? = null,
    318     icon: ImageVector? = null,
    319     enabled: Boolean = true,
    320     onClick: (() -> Unit)? = null,
    321 ) {
    322     Row(
    323         modifier = Modifier
    324             .fillMaxWidth()
    325             .clickable(enabled = enabled && onClick != null) { onClick?.invoke() }
    326             .padding(16.dp),
    327         verticalAlignment = Alignment.CenterVertically
    328     ) {
    329         if (icon != null) {
    330             Icon(
    331                 imageVector = icon,
    332                 contentDescription = null,
    333                 modifier = Modifier.size(24.dp),
    334                 tint = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
    335             )
    336         }
    337         Column(
    338             modifier = Modifier
    339                 .padding(start = if (icon != null) 24.dp else 0.dp)
    340                 .weight(1f)
    341         ) {
    342             Text(
    343                 text = title,
    344                 style = MaterialTheme.typography.titleMedium,
    345                 color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
    346                 modifier = Modifier.padding(bottom = 3.dp),
    347             )
    348             if (summary != null) {
    349                 Text(
    350                     text = summary,
    351                     style = MaterialTheme.typography.bodyMedium,
    352                     color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
    353                 )
    354             }
    355         }
    356     }
    357 }
    358 
    359 @Composable
    360 fun SettingsSwitchItem(
    361     title: String,
    362     summary: String? = null,
    363     icon: ImageVector? = null,
    364     checked: Boolean,
    365     onCheckedChange: (Boolean) -> Unit,
    366 ) {
    367     Row(
    368         modifier = Modifier
    369             .fillMaxWidth()
    370             .clickable { onCheckedChange(!checked) }
    371             .padding(16.dp),
    372         verticalAlignment = Alignment.CenterVertically
    373     ) {
    374         if (icon != null) {
    375             Icon(
    376                 imageVector = icon,
    377                 contentDescription = null,
    378                 modifier = Modifier.size(24.dp)
    379             )
    380         }
    381         Column(
    382             modifier = Modifier
    383                 .padding(start = if (icon != null) 24.dp else 0.dp)
    384                 .weight(1f)
    385         ) {
    386             Text(
    387                 text = title,
    388                 style = MaterialTheme.typography.titleMedium,
    389                 modifier = Modifier.padding(bottom = 3.dp),
    390             )
    391             if (summary != null) {
    392                 Text(
    393                     text = summary,
    394                     style = MaterialTheme.typography.bodyMedium,
    395                     color = MaterialTheme.colorScheme.onSurfaceVariant
    396                 )
    397             }
    398         }
    399         Switch(
    400             modifier = Modifier.padding(start = 16.dp),
    401             checked = checked,
    402             onCheckedChange = onCheckedChange
    403         )
    404     }
    405 }
    406 
    407 private fun enableBiometrics(
    408     context: Context,
    409     settingsManager: SettingsManager,
    410     prompt: Boolean,
    411     biometricAuthUnavailableMessage: String,
    412     onPromptEnrollment: (Intent) -> Unit,
    413 ): Boolean {
    414     val biometricManager = BiometricManager.from(context)
    415     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
    416         when (biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)) {
    417             BIOMETRIC_SUCCESS -> {
    418                 settingsManager.setBiometricLockEnabled(context, true)
    419                 return true
    420             }
    421 
    422             BIOMETRIC_ERROR_NONE_ENROLLED -> {
    423                 Toast.makeText(
    424                     context,
    425                     biometricAuthUnavailableMessage,
    426                     Toast.LENGTH_SHORT,
    427                 ).show()
    428 
    429                 if (prompt) {
    430                     promptAuthEnrollment(onPromptEnrollment)
    431                 }
    432             }
    433 
    434             else -> Toast.makeText(
    435                 context,
    436                 biometricAuthUnavailableMessage,
    437                 Toast.LENGTH_SHORT,
    438             ).show()
    439         }
    440     } else {
    441         val keyguardManager = context.getSystemService(KEYGUARD_SERVICE) as KeyguardManager
    442         if (keyguardManager.isDeviceSecure) {
    443             settingsManager.setBiometricLockEnabled(context, true)
    444             return true
    445         } else {
    446             Toast.makeText(
    447                 context,
    448                 biometricAuthUnavailableMessage,
    449                 Toast.LENGTH_SHORT,
    450             ).show()
    451 
    452             if (prompt) {
    453                 promptAuthEnrollment(onPromptEnrollment)
    454             }
    455         }
    456     }
    457 
    458     return false
    459 }
    460 
    461 private fun promptAuthEnrollment(onPromptEnrollment: (Intent) -> Unit) {
    462     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
    463         val intent = Intent(ACTION_BIOMETRIC_ENROLL).apply {
    464             putExtra(
    465                 EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED,
    466                 BIOMETRIC_STRONG or DEVICE_CREDENTIAL
    467             )
    468         }
    469         onPromptEnrollment(intent)
    470     } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
    471         val intent = Intent(ACTION_FINGERPRINT_ENROLL)
    472         onPromptEnrollment(intent)
    473     } else {
    474         val intent = Intent(ACTION_SECURITY_SETTINGS)
    475         onPromptEnrollment(intent)
    476     }
    477 }