MainActivity.kt (13430B)
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.main 18 19 import android.content.Intent 20 import android.content.Intent.ACTION_SEND 21 import android.content.Intent.ACTION_VIEW 22 import android.content.Intent.EXTRA_TEXT 23 import android.nfc.NdefMessage 24 import android.nfc.NfcAdapter 25 import android.os.Build 26 import android.os.Bundle 27 import android.util.Log 28 import android.widget.Toast 29 import android.widget.Toast.LENGTH_SHORT 30 import androidx.activity.compose.setContent 31 import androidx.activity.enableEdgeToEdge 32 import androidx.activity.viewModels 33 import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG 34 import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL 35 import androidx.biometric.BiometricPrompt 36 import androidx.biometric.BiometricPrompt.ERROR_NO_BIOMETRICS 37 import androidx.biometric.BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL 38 import androidx.compose.foundation.Image 39 import androidx.compose.foundation.background 40 import androidx.compose.foundation.layout.Box 41 import androidx.compose.foundation.layout.Column 42 import androidx.compose.foundation.layout.fillMaxSize 43 import androidx.compose.foundation.layout.padding 44 import androidx.compose.foundation.layout.size 45 import androidx.compose.material3.Button 46 import androidx.compose.material3.ExperimentalMaterial3Api 47 import androidx.compose.material3.MaterialTheme 48 import androidx.compose.material3.Text 49 import androidx.compose.material3.rememberModalBottomSheetState 50 import androidx.compose.runtime.Composable 51 import androidx.compose.runtime.DisposableEffect 52 import androidx.compose.runtime.collectAsState 53 import androidx.compose.runtime.getValue 54 import androidx.compose.runtime.livedata.observeAsState 55 import androidx.compose.runtime.mutableStateOf 56 import androidx.compose.runtime.remember 57 import androidx.compose.runtime.setValue 58 import androidx.compose.ui.Alignment 59 import androidx.compose.ui.Modifier 60 import androidx.compose.ui.graphics.ColorFilter 61 import androidx.compose.ui.graphics.vector.ImageVector 62 import androidx.compose.ui.res.stringResource 63 import androidx.compose.ui.res.vectorResource 64 import androidx.compose.ui.unit.dp 65 import androidx.core.content.ContextCompat 66 import androidx.fragment.app.FragmentActivity 67 import androidx.lifecycle.Lifecycle 68 import androidx.lifecycle.lifecycleScope 69 import androidx.lifecycle.repeatOnLifecycle 70 import androidx.navigation.NavController 71 import androidx.navigation.compose.rememberNavController 72 import com.google.zxing.client.android.Intents.Scan.MIXED_SCAN 73 import com.google.zxing.client.android.Intents.Scan.SCAN_TYPE 74 import com.journeyapps.barcodescanner.ScanContract 75 import com.journeyapps.barcodescanner.ScanOptions 76 import com.journeyapps.barcodescanner.ScanOptions.QR_CODE 77 import kotlinx.coroutines.flow.MutableStateFlow 78 import kotlinx.coroutines.launch 79 import net.taler.common.EventObserver 80 import net.taler.lib.android.TalerNfcService 81 import net.taler.wallet.R 82 import net.taler.wallet.WalletDestination 83 import net.taler.wallet.WalletNavHost 84 import net.taler.wallet.backend.TalerErrorInfo 85 import net.taler.wallet.compose.ErrorBottomSheet 86 import net.taler.wallet.events.ObservabilityDialog 87 import net.taler.wallet.launchInAppBrowser 88 import net.taler.wallet.transactions.TransactionPeerPullCredit 89 import net.taler.wallet.transactions.TransactionPeerPushDebit 90 import net.taler.wallet.ui.theme.TalerTheme 91 92 class MainActivity : FragmentActivity() { 93 private val model: MainViewModel by viewModels() 94 95 private val launchIntentUri = MutableStateFlow<String?>(null) 96 private var nav: NavController? = null 97 private lateinit var biometricPrompt: BiometricPrompt 98 private lateinit var promptInfo: BiometricPrompt.PromptInfo 99 100 private val barcodeLauncher = registerForActivityResult(ScanContract()) { result -> 101 model.unlockWallet() // hack to prevent from locking after scanning QR 102 if (result == null || result.contents == null) return@registerForActivityResult 103 nav?.navigate(WalletDestination.HandleUri(result.contents)) 104 } 105 106 @OptIn(ExperimentalMaterial3Api::class) 107 override fun onCreate(savedInstanceState: Bundle?) { 108 enableEdgeToEdge() 109 super.onCreate(savedInstanceState) 110 setupBiometrics() 111 112 TalerNfcService.startService(this) 113 114 setContent { 115 TalerTheme { 116 val navController = rememberNavController() 117 nav = navController 118 var errorInfo by remember { mutableStateOf<TalerErrorInfo?>(null) } 119 val showObservabilityLog by model.showObservabilityLog.collectAsState(false) 120 val devMode by model.devMode.observeAsState(false) 121 val errorSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = !devMode) 122 val authenticated by model.authenticated.collectAsState() 123 val biometricEnabled by model.settingsManager.getBiometricLockEnabled(this).collectAsState(false) 124 val launchUri by launchIntentUri.collectAsState(null) 125 126 DisposableEffect(Unit) { 127 onDispose { 128 launchIntentUri.value = null 129 } 130 } 131 132 Box(Modifier.fillMaxSize()) { 133 WalletNavHost( 134 navController = navController, 135 model = model, 136 modifier = Modifier.fillMaxSize(), 137 launchUri = launchUri, 138 onScanQr = { model.scanCode() }, 139 onFulfillPayment = { url: String -> launchInAppBrowser(this@MainActivity, url) }, 140 onShowError = { errorInfo = it } 141 ) 142 143 if (!authenticated && biometricEnabled) { 144 BiometricOverlay( 145 onUnlock = { biometricPrompt.authenticate(promptInfo) } 146 ) 147 } 148 } 149 150 if (showObservabilityLog) { 151 val events by model.observabilityLog.collectAsState() 152 ObservabilityDialog(events.reversed()) { 153 model.hideObservabilityLog() 154 } 155 } 156 157 errorInfo?.let { 158 ErrorBottomSheet( 159 error = it, 160 devMode = devMode, 161 sheetState = errorSheetState, 162 onDismiss = { errorInfo = null } 163 ) 164 } 165 } 166 } 167 168 model.startWallet() 169 170 handleIntents(intent) 171 172 // Update devMode in model from Datastore API 173 lifecycleScope.launch { 174 repeatOnLifecycle(Lifecycle.State.STARTED) { 175 model.settingsManager.getDevModeEnabled(this@MainActivity).collect { enabled -> 176 model.setDevMode(enabled) {} 177 } 178 } 179 } 180 181 lifecycleScope.launch { 182 repeatOnLifecycle(Lifecycle.State.STARTED) { 183 model.transactionManager.selectedTransaction.collect { tx -> 184 TalerNfcService.clearNdefPayload(this@MainActivity) 185 186 when (tx) { 187 is TransactionPeerPushDebit -> tx.talerUri 188 is TransactionPeerPullCredit -> tx.talerUri 189 else -> return@collect 190 }?.let { uri -> 191 Log.d(TAG, "Transaction ${tx.transactionId} selected with URI $uri") 192 TalerNfcService.setUri(this@MainActivity, uri) 193 } 194 } 195 } 196 } 197 198 model.scanCodeEvent.observe(this, EventObserver { 199 val scanOptions = ScanOptions().apply { 200 setPrompt("") 201 setBeepEnabled(false) // FIXME: expose in settings 202 setOrientationLocked(false) 203 setDesiredBarcodeFormats(QR_CODE) 204 addExtra(SCAN_TYPE, MIXED_SCAN) 205 } 206 if (it) barcodeLauncher.launch(scanOptions) 207 }) 208 209 model.networkManager.networkStatus.observe(this) { online -> 210 model.hintNetworkAvailability(online) 211 } 212 } 213 214 private fun setupBiometrics() { 215 biometricPrompt = BiometricPrompt( 216 this, 217 ContextCompat.getMainExecutor(this), 218 object : BiometricPrompt.AuthenticationCallback() { 219 override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { 220 super.onAuthenticationError(errorCode, errString) 221 if (errorCode == ERROR_NO_BIOMETRICS || errorCode == ERROR_NO_DEVICE_CREDENTIAL) { 222 model.unlockWallet() 223 } 224 Toast.makeText(this@MainActivity, getString(R.string.biometric_auth_error, errString), LENGTH_SHORT).show() 225 } 226 227 override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { 228 super.onAuthenticationSucceeded(result) 229 model.unlockWallet() 230 } 231 232 override fun onAuthenticationFailed() { 233 super.onAuthenticationFailed() 234 Toast.makeText(this@MainActivity, getString(R.string.biometric_auth_failed), LENGTH_SHORT).show() 235 } 236 }, 237 ) 238 239 promptInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 240 BiometricPrompt.PromptInfo.Builder() 241 .setTitle(getString(R.string.biometric_prompt_title)) 242 .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) 243 .setConfirmationRequired(true) 244 .build() 245 } else { 246 BiometricPrompt.PromptInfo.Builder() 247 .setTitle(getString(R.string.biometric_prompt_title)) 248 .setDeviceCredentialAllowed(true) 249 .setConfirmationRequired(true) 250 .build() 251 } 252 } 253 254 override fun onNewIntent(intent: Intent) { 255 super.onNewIntent(intent) 256 handleIntents(intent) 257 } 258 259 private fun handleIntents(intent: Intent?) { 260 if (intent == null) return 261 262 if (intent.action == ACTION_VIEW) intent.dataString?.let { uri -> 263 launchIntentUri.value = uri 264 } 265 266 if (intent.action == ACTION_SEND) { 267 if (intent.type == "text/plain") { 268 intent.getStringExtra(EXTRA_TEXT)?.let { uri -> 269 launchIntentUri.value = uri 270 } 271 } 272 } 273 274 if (intent.action == NfcAdapter.ACTION_NDEF_DISCOVERED) { 275 val messages: Array<NdefMessage> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 276 intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES, NdefMessage::class.java) 277 } else { 278 @Suppress("DEPRECATION") 279 intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)?.let { rawMessages -> 280 rawMessages.map { it as NdefMessage } 281 }?.toTypedArray() 282 } ?: return 283 284 messages.forEach { message -> 285 message.records?.forEach { record -> 286 record.toUri()?.let { uri -> 287 launchIntentUri.value = uri.toString() 288 } 289 } 290 } 291 } 292 } 293 294 override fun onResume() { 295 super.onResume() 296 TalerNfcService.setDefaultHandler(this) 297 } 298 299 override fun onPause() { 300 super.onPause() 301 TalerNfcService.unsetDefaultHandler(this) 302 } 303 304 override fun onStop() { 305 super.onStop() 306 model.lockWallet() 307 } 308 309 override fun onDestroy() { 310 super.onDestroy() 311 TalerNfcService.clearNdefPayload(this) 312 TalerNfcService.stopService(this) 313 model.stopWallet() 314 } 315 } 316 317 @Composable 318 fun BiometricOverlay(onUnlock: () -> Unit) { 319 Box( 320 modifier = Modifier 321 .fillMaxSize() 322 .background(MaterialTheme.colorScheme.surface) 323 ) { 324 Column( 325 modifier = Modifier.align(Alignment.Center), 326 horizontalAlignment = Alignment.CenterHorizontally 327 ) { 328 Image( 329 imageVector = ImageVector.vectorResource(id = R.drawable.ic_shield), 330 contentDescription = null, 331 modifier = Modifier 332 .size(64.dp) 333 .padding(bottom = 24.dp), 334 colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) 335 ) 336 337 Button(onClick = onUnlock) { 338 Text(stringResource(R.string.biometric_unlock_label)) 339 } 340 } 341 } 342 }