taler-android

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

MainActivity.kt (13873B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2025 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
     18 
     19 import android.content.Intent
     20 import android.content.Intent.ACTION_VIEW
     21 import android.nfc.NdefMessage
     22 import android.nfc.NfcAdapter
     23 import android.os.Build
     24 import android.os.Bundle
     25 import android.util.Log
     26 import android.view.Menu
     27 import android.view.MenuItem
     28 import android.view.View.GONE
     29 import android.view.View.VISIBLE
     30 import android.view.ViewGroup.MarginLayoutParams
     31 import android.widget.Toast
     32 import android.widget.Toast.LENGTH_SHORT
     33 import androidx.activity.enableEdgeToEdge
     34 import androidx.activity.viewModels
     35 import androidx.appcompat.app.AppCompatActivity
     36 import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
     37 import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
     38 import androidx.biometric.BiometricPrompt
     39 import androidx.biometric.BiometricPrompt.ERROR_NO_BIOMETRICS
     40 import androidx.biometric.BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL
     41 import androidx.core.content.ContextCompat
     42 import androidx.core.os.bundleOf
     43 import androidx.core.view.ViewCompat
     44 import androidx.core.view.WindowInsetsCompat
     45 import androidx.core.view.updateLayoutParams
     46 import androidx.lifecycle.Lifecycle
     47 import androidx.lifecycle.lifecycleScope
     48 import androidx.lifecycle.repeatOnLifecycle
     49 import androidx.navigation.NavController
     50 import androidx.navigation.fragment.NavHostFragment
     51 import androidx.navigation.ui.setupActionBarWithNavController
     52 import androidx.preference.Preference
     53 import androidx.preference.PreferenceFragmentCompat
     54 import androidx.preference.PreferenceFragmentCompat.OnPreferenceStartFragmentCallback
     55 import com.google.android.material.dialog.MaterialAlertDialogBuilder
     56 import com.google.zxing.client.android.Intents.Scan.MIXED_SCAN
     57 import com.google.zxing.client.android.Intents.Scan.SCAN_TYPE
     58 import com.journeyapps.barcodescanner.ScanContract
     59 import com.journeyapps.barcodescanner.ScanOptions
     60 import com.journeyapps.barcodescanner.ScanOptions.QR_CODE
     61 import kotlinx.coroutines.flow.combine
     62 import kotlinx.coroutines.launch
     63 import net.taler.common.EventObserver
     64 import net.taler.lib.android.TalerNfcService
     65 import net.taler.wallet.databinding.ActivityMainBinding
     66 import net.taler.wallet.events.ObservabilityDialog
     67 import net.taler.wallet.transactions.TransactionPeerPullCredit
     68 import net.taler.wallet.transactions.TransactionPeerPushDebit
     69 
     70 class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback {
     71     private val model: MainViewModel by viewModels()
     72 
     73     private lateinit var ui: ActivityMainBinding
     74     private lateinit var nav: NavController
     75     private lateinit var biometricPrompt: BiometricPrompt
     76     private lateinit var promptInfo: BiometricPrompt.PromptInfo
     77 
     78     private val barcodeLauncher = registerForActivityResult(ScanContract()) { result ->
     79         model.unlockWallet() // hack to prevent from locking after scanning QR
     80         if (result == null || result.contents == null) return@registerForActivityResult
     81         if (model.checkScanQrContext(result.contents)) {
     82             handleTalerUri(result.contents, "QR code")
     83         } else {
     84             confirmTalerUri(result.contents, "QR code")
     85         }
     86     }
     87 
     88     override fun onCreate(savedInstanceState: Bundle?) {
     89         enableEdgeToEdge()
     90         super.onCreate(savedInstanceState)
     91         ui = ActivityMainBinding.inflate(layoutInflater)
     92         setContentView(ui.root)
     93         setupInsets()
     94         setupBiometrics()
     95 
     96         TalerNfcService.startService(this)
     97 
     98         val navHostFragment =
     99             supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
    100         nav = navHostFragment.navController
    101 
    102         setSupportActionBar(ui.toolbar)
    103         setupActionBarWithNavController(nav)
    104         ui.toolbar.setNavigationOnClickListener {
    105             if (onBackPressedDispatcher.hasEnabledCallbacks()) {
    106                 onBackPressedDispatcher.onBackPressed()
    107             } else {
    108                 nav.navigateUp()
    109             }
    110         }
    111 
    112         model.startWallet()
    113 
    114         // TODO: refactor and unify progress bar handling
    115         // model.showProgressBar.observe(this) { show ->
    116         //     ui.content.progressBar.visibility = if (show) VISIBLE else INVISIBLE
    117         // }
    118 
    119         handleIntents(intent)
    120 
    121         // Update devMode in model from Datastore API
    122         lifecycleScope.launch {
    123             repeatOnLifecycle(Lifecycle.State.STARTED) {
    124                 model.settingsManager.getDevModeEnabled(this@MainActivity).collect { enabled ->
    125                     model.setDevMode(enabled) { error ->
    126                         showError(error)
    127                     }
    128                 }
    129             }
    130         }
    131 
    132         lifecycleScope.launch {
    133             repeatOnLifecycle(Lifecycle.State.STARTED) {
    134                 model.transactionManager.selectedTransaction.collect { tx ->
    135                     TalerNfcService.clearUri(this@MainActivity)
    136 
    137                     when (tx) {
    138                         is TransactionPeerPushDebit -> tx.talerUri
    139                         is TransactionPeerPullCredit -> tx.talerUri
    140                         else -> return@collect
    141                     }?.let { uri ->
    142                         Log.d(TAG, "Transaction ${tx.transactionId} selected with URI $uri")
    143                         TalerNfcService.setUri(this@MainActivity, uri)
    144                     }
    145                 }
    146             }
    147         }
    148 
    149         lifecycleScope.launch {
    150             repeatOnLifecycle(Lifecycle.State.STARTED) {
    151                 model.viewMode.collect { tx ->
    152                     model.settingsManager.saveViewMode(this@MainActivity, tx)
    153                 }
    154             }
    155         }
    156 
    157         model.scanCodeEvent.observe(this, EventObserver {
    158             val scanOptions = ScanOptions().apply {
    159                 setPrompt("")
    160                 setBeepEnabled(true)
    161                 setOrientationLocked(false)
    162                 setDesiredBarcodeFormats(QR_CODE)
    163                 addExtra(SCAN_TYPE, MIXED_SCAN)
    164             }
    165             if (it) barcodeLauncher.launch(scanOptions)
    166         })
    167 
    168         model.networkManager.networkStatus.observe(this) { online ->
    169             ui.offlineBanner.visibility = if (online) GONE else VISIBLE
    170             model.hintNetworkAvailability(online)
    171         }
    172 
    173         model.devMode.observe(this) {
    174             invalidateMenu()
    175         }
    176     }
    177 
    178     private fun setupInsets() {
    179         // We really don't want to deal with cutouts!
    180         ViewCompat.setOnApplyWindowInsetsListener(ui.root) { v, windowInsets ->
    181             val insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
    182             v.updateLayoutParams<MarginLayoutParams> {
    183                 leftMargin = insets.left
    184                 rightMargin = insets.right
    185             }
    186             windowInsets
    187         }
    188 
    189         ViewCompat.setOnApplyWindowInsetsListener(ui.toolbar) { v, windowInsets ->
    190             val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
    191             v.updateLayoutParams<MarginLayoutParams> {
    192                 leftMargin = insets.left
    193                 rightMargin = insets.right
    194             }
    195             windowInsets
    196         }
    197     }
    198 
    199     private fun setupBiometrics() {
    200         lifecycleScope.launch {
    201             repeatOnLifecycle(Lifecycle.State.STARTED) {
    202                 combine(
    203                     model.authenticated,
    204                     model.settingsManager.getBiometricLockEnabled(this@MainActivity)
    205                 ) { a, b -> a to b }.collect { c ->
    206                     val authenticated = c.first
    207                     val biometricEnabled = c.second
    208                     if (!authenticated && biometricEnabled) {
    209                         ui.biometricOverlay.visibility = VISIBLE
    210                         biometricPrompt.authenticate(promptInfo)
    211                     } else {
    212                         ui.biometricOverlay.visibility = GONE
    213                     }
    214                 }
    215             }
    216         }
    217 
    218         ui.unlockButton.setOnClickListener {
    219             biometricPrompt.authenticate(promptInfo)
    220         }
    221 
    222         biometricPrompt = BiometricPrompt(
    223             this,
    224             ContextCompat.getMainExecutor(this),
    225             object : BiometricPrompt.AuthenticationCallback() {
    226                 override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
    227                     super.onAuthenticationError(errorCode, errString)
    228                     if (errorCode == ERROR_NO_BIOMETRICS || errorCode == ERROR_NO_DEVICE_CREDENTIAL) {
    229                         model.unlockWallet()
    230                     }
    231                     Toast.makeText(this@MainActivity, getString(R.string.biometric_auth_error, errString), LENGTH_SHORT).show()
    232                 }
    233 
    234                 override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
    235                     super.onAuthenticationSucceeded(result)
    236                     model.unlockWallet()
    237                 }
    238 
    239                 override fun onAuthenticationFailed() {
    240                     super.onAuthenticationFailed()
    241                     Toast.makeText(this@MainActivity, getString(R.string.biometric_auth_failed), LENGTH_SHORT).show()
    242                 }
    243             },
    244         )
    245 
    246         promptInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
    247             BiometricPrompt.PromptInfo.Builder()
    248                 .setTitle(getString(R.string.biometric_prompt_title))
    249                 .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
    250                 .setConfirmationRequired(true)
    251                 .build()
    252         } else {
    253             BiometricPrompt.PromptInfo.Builder()
    254                 .setTitle(getString(R.string.biometric_prompt_title))
    255                 .setDeviceCredentialAllowed(true)
    256                 .setConfirmationRequired(true)
    257                 .build()
    258         }
    259     }
    260 
    261     override fun onNewIntent(intent: Intent) {
    262         super.onNewIntent(intent)
    263         handleIntents(intent)
    264     }
    265 
    266     private fun handleIntents(intent: Intent?) {
    267         if (intent == null) return
    268 
    269         if (intent.action == ACTION_VIEW) intent.dataString?.let { uri ->
    270             handleTalerUri(uri, "intent")
    271         }
    272 
    273         if (intent.action == NfcAdapter.ACTION_NDEF_DISCOVERED) {
    274             val messages: Array<NdefMessage> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    275                 intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES, NdefMessage::class.java)
    276             } else {
    277                 @Suppress("DEPRECATION")
    278                 intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)?.let { rawMessages ->
    279                     rawMessages.map { it as NdefMessage }
    280                 }?.toTypedArray()
    281             } ?: return
    282 
    283             messages.forEach { message ->
    284                 message.records?.forEach { record ->
    285                     record.toUri()?.let { uri ->
    286                         handleTalerUri(uri.toString(), "nfc")
    287                     }
    288                 }
    289             }
    290         }
    291     }
    292 
    293     override fun onCreateOptionsMenu(menu: Menu?): Boolean {
    294         if (model.devMode.value == true) {
    295             menuInflater.inflate(R.menu.global_dev, menu)
    296         }
    297 
    298         return super.onCreateOptionsMenu(menu)
    299     }
    300 
    301     override fun onOptionsItemSelected(item: MenuItem): Boolean {
    302         when (item.itemId) {
    303             R.id.action_show_logs -> {
    304                 ObservabilityDialog().show(supportFragmentManager, "OBSERVABILITY")
    305             }
    306         }
    307         return super.onOptionsItemSelected(item)
    308     }
    309 
    310     private fun confirmTalerUri(uri: String, from: String) {
    311         MaterialAlertDialogBuilder(this).apply {
    312             setTitle(R.string.qr_scan_context_title)
    313             setMessage(when (model.getScanQrContext()) {
    314                 ScanQrContext.Send -> R.string.qr_scan_context_send_message
    315                 ScanQrContext.Receive -> R.string.qr_scan_context_receive_message
    316                 else -> error("invalid value")
    317             })
    318 
    319             setNegativeButton(R.string.ok) { _, _ ->
    320                 handleTalerUri(uri, from)
    321             }
    322 
    323             setNeutralButton(R.string.cancel) { dialog, _ ->
    324                 dialog.dismiss()
    325             }
    326         }.show()
    327     }
    328 
    329     private fun handleTalerUri(uri: String, from: String) {
    330         val args = bundleOf("uri" to uri, "from" to from)
    331         nav.navigate(R.id.action_global_handle_uri, args)
    332     }
    333 
    334     override fun onPreferenceStartFragment(
    335         caller: PreferenceFragmentCompat,
    336         pref: Preference,
    337     ): Boolean {
    338         when (pref.key) {
    339             "pref_exchanges" -> nav.navigate(R.id.nav_settings_exchanges)
    340             "pref_accounts" -> nav.navigate(R.id.bankAccountsFragment)
    341             "pref_donau" -> nav.navigate(R.id.nav_settings_donau)
    342         }
    343         return true
    344     }
    345 
    346     override fun onResume() {
    347         super.onResume()
    348         TalerNfcService.setDefaultHandler(this)
    349     }
    350 
    351     override fun onPause() {
    352         super.onPause()
    353         TalerNfcService.unsetDefaultHandler(this)
    354     }
    355 
    356     override fun onStop() {
    357         super.onStop()
    358         model.lockWallet()
    359     }
    360 
    361     override fun onDestroy() {
    362         super.onDestroy()
    363         TalerNfcService.stopService(this)
    364         TalerNfcService.clearUri(this)
    365         model.stopWallet()
    366     }
    367 }