taler-android

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

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 }