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 }