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 }