taler-android

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

HandleUriFragment.kt (12003B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2024 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.net.Uri
     20 import android.os.Bundle
     21 import android.util.Log
     22 import android.view.LayoutInflater
     23 import android.view.View
     24 import android.view.ViewGroup
     25 import android.widget.Toast.LENGTH_LONG
     26 import androidx.compose.runtime.getValue
     27 import androidx.compose.runtime.livedata.observeAsState
     28 import androidx.compose.ui.platform.ComposeView
     29 import androidx.core.os.bundleOf
     30 import androidx.fragment.app.Fragment
     31 import androidx.fragment.app.activityViewModels
     32 import androidx.lifecycle.MutableLiveData
     33 import androidx.lifecycle.Observer
     34 import androidx.lifecycle.lifecycleScope
     35 import androidx.lifecycle.viewModelScope
     36 import androidx.navigation.fragment.findNavController
     37 import com.google.android.material.snackbar.Snackbar
     38 import kotlinx.coroutines.Dispatchers
     39 import kotlinx.coroutines.launch
     40 import net.taler.common.isOnline
     41 import net.taler.common.showError
     42 import net.taler.wallet.compose.LoadingScreen
     43 import net.taler.wallet.compose.RetryScreen
     44 import net.taler.wallet.compose.TalerSurface
     45 import net.taler.wallet.refund.RefundStatus
     46 import java.io.IOException
     47 import java.net.HttpURLConnection
     48 import java.net.URL
     49 import java.util.Locale
     50 import androidx.core.net.toUri
     51 import net.taler.wallet.main.MainViewModel
     52 import net.taler.wallet.main.TAG
     53 
     54 class HandleUriFragment: Fragment() {
     55     private val model: MainViewModel by activityViewModels()
     56 
     57     lateinit var uri: String
     58     lateinit var from: String
     59 
     60     private var processing = false
     61 
     62     override fun onCreateView(
     63         inflater: LayoutInflater,
     64         container: ViewGroup?,
     65         savedInstanceState: Bundle?,
     66     ): View {
     67         uri = arguments?.getString("uri") ?: error("no uri passed")
     68         from = arguments?.getString("from") ?: error("no from passed")
     69 
     70         return ComposeView(requireContext()).apply {
     71             setContent {
     72                 TalerSurface {
     73                     val networkStatus by model.networkManager.networkStatus.observeAsState()
     74                     if (networkStatus == true) {
     75                         LoadingScreen()
     76                     } else {
     77                         RetryScreen {
     78                             processTalerUri()
     79                         }
     80                     }
     81                 }
     82             }
     83         }
     84     }
     85 
     86     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
     87         super.onViewCreated(view, savedInstanceState)
     88         model.networkManager.networkStatus.observe(viewLifecycleOwner) { status ->
     89             if (status) {
     90                 processTalerUri()
     91             }
     92         }
     93     }
     94 
     95     override fun onStart() {
     96         super.onStart()
     97         processing = false
     98     }
     99 
    100     private fun processTalerUri() {
    101         // TODO: pressing "Retry" button manually will not stop the
    102         //  user from losing the QR code in case the action fails.
    103         //  (i.e. "Retry" can only be pressed once)
    104         if (processing) return
    105         processing = true
    106 
    107         val uri = uri.trim().toUri()
    108         if (uri.fragment != null && !requireContext().isOnline()) {
    109             connectToWifi(requireContext(), uri.fragment!!)
    110         }
    111 
    112         // TODO: fix this bad async programming, make it only async when needed.
    113         getTalerAction(uri, 3, MutableLiveData<String>()).observe(viewLifecycleOwner) { u ->
    114             Log.v(TAG, "found action $u")
    115 
    116             if (u.startsWith("payto://", ignoreCase = true)) {
    117                 Log.v(TAG, "navigating with paytoUri!")
    118                 val bundle = bundleOf("uri" to u)
    119                 findNavController().navigate(R.id.action_global_paytoUri, bundle)
    120                 return@observe
    121             }
    122 
    123             val normalizedURL = u.lowercase(Locale.ROOT)
    124             var ext = false
    125             val action = normalizedURL.substring(
    126                 if (normalizedURL.startsWith("taler://", ignoreCase = true)) {
    127                     "taler://".length
    128                 } else if (normalizedURL.startsWith("ext+taler://", ignoreCase = true)) {
    129                     ext = true
    130                     "ext+taler://".length
    131                 } else if (normalizedURL.startsWith("taler+http://", ignoreCase = true) &&
    132                     model.devMode.value == true
    133                 ) {
    134                     "taler+http://".length
    135                 } else {
    136                     normalizedURL.length
    137                 }
    138             )
    139 
    140             // Remove ext+ scheme prefix if present
    141             val u2 = if (ext) {
    142                 "taler://" + u.substring("ext+taler://".length)
    143             } else u
    144 
    145             when {
    146                 action.startsWith("pay/", ignoreCase = true) -> run {
    147                     Log.v(TAG, "navigating!")
    148                     findNavController().navigate(R.id.action_global_promptPayment)
    149                     model.paymentManager.preparePay(u2)
    150                 }
    151                 action.startsWith("withdraw/", ignoreCase = true) -> run {
    152                     Log.v(TAG, "navigating!")
    153                     // there's more than one entry point, so use global action
    154                     val args = bundleOf(
    155                         "withdrawUri" to u2,
    156                         "editableCurrency" to false,
    157                     )
    158                     model.withdrawManager.resetWithdrawal()
    159                     findNavController().navigate(R.id.action_global_promptWithdraw, args)
    160                 }
    161 
    162                 action.startsWith("withdraw-exchange/", ignoreCase = true) -> run {
    163                     Log.v(TAG, "navigating!")
    164                     val args = bundleOf(
    165                         "withdrawExchangeUri" to u2,
    166                         "editableCurrency" to false,
    167                     )
    168                     model.withdrawManager.resetWithdrawal()
    169                     findNavController().navigate(R.id.action_global_promptWithdraw, args)
    170                 }
    171 
    172                 action.startsWith("refund/", ignoreCase = true) -> run {
    173                     model.showProgressBar.value = true
    174                     model.refundManager.refund(u2).observe(viewLifecycleOwner, Observer(::onRefundResponse))
    175                 }
    176                 action.startsWith("pay-pull/", ignoreCase = true) -> run {
    177                     findNavController().navigate(R.id.action_global_promptPullPayment)
    178                     model.peerManager.preparePeerPullDebit(u2)
    179                 }
    180                 action.startsWith("pay-push/", ignoreCase = true) -> run {
    181                     findNavController().navigate(R.id.action_global_promptPushPayment)
    182                     model.peerManager.preparePeerPushCredit(u2)
    183                 }
    184                 action.startsWith("pay-template/", ignoreCase = true) -> {
    185                     val bundle = bundleOf("uri" to u2)
    186                     findNavController().navigate(R.id.action_global_promptPayTemplate, bundle)
    187                 }
    188                 action.startsWith("dev-experiment/", ignoreCase = true) -> {
    189                     model.applyDevExperiment(u2) { error ->
    190                         showError(error)
    191                     }
    192                     findNavController().navigate(R.id.action_global_main)
    193                 }
    194                 else -> {
    195                     showError(R.string.error_unsupported_uri, "From: $from\nURI: $u2")
    196                     findNavController().popBackStack()
    197                 }
    198             }
    199         }
    200     }
    201 
    202     private fun getTalerAction(
    203         uri: Uri,
    204         maxRedirects: Int,
    205         actionFound: MutableLiveData<String>,
    206     ): MutableLiveData<String> {
    207         val scheme = uri.scheme ?: return actionFound
    208 
    209         if (scheme == "http" || scheme == "https") {
    210             model.viewModelScope.launch(Dispatchers.IO) {
    211                 val conn = URL(uri.toString()).openConnection() as HttpURLConnection
    212                 Log.v(TAG, "prepare query: $uri")
    213                 conn.setRequestProperty("Accept", "text/html")
    214                 conn.connectTimeout = 5000
    215                 conn.requestMethod = "HEAD"
    216                 try {
    217                     conn.connect()
    218                 } catch (e: IOException) {
    219                     Log.e(TAG, "Error connecting to $uri ", e)
    220                     showError(R.string.error_broken_uri, "$uri")
    221                     activity?.runOnUiThread {
    222                         findNavController().popBackStack()
    223                     }
    224                     return@launch
    225                 }
    226                 val status = conn.responseCode
    227 
    228                 if (status == HttpURLConnection.HTTP_OK || status == HttpURLConnection.HTTP_PAYMENT_REQUIRED) {
    229                     val talerHeader = conn.headerFields["Taler"]
    230                     if (talerHeader != null && talerHeader[0] != null) {
    231                         Log.v(TAG, "taler header: ${talerHeader[0]}")
    232                         val talerHeaderUri = Uri.parse(talerHeader[0])
    233                         getTalerAction(talerHeaderUri, 0, actionFound)
    234                     } else {
    235                         showError(R.string.error_no_uri, "$uri")
    236                         activity?.runOnUiThread {
    237                             findNavController().popBackStack()
    238                         }
    239                         return@launch
    240                     }
    241                 } else if (status == HttpURLConnection.HTTP_MOVED_TEMP
    242                     || status == HttpURLConnection.HTTP_MOVED_PERM
    243                     || status == HttpURLConnection.HTTP_SEE_OTHER
    244                 ) {
    245                     val location = conn.headerFields["Location"]
    246                     if (location != null && location[0] != null) {
    247                         Log.v(TAG, "location redirect: ${location[0]}")
    248                         val locUri = Uri.parse(location[0])
    249                         getTalerAction(locUri, maxRedirects - 1, actionFound)
    250                     }
    251                 } else {
    252                     showError(R.string.error_broken_uri, "$uri")
    253                     activity?.runOnUiThread {
    254                         findNavController().popBackStack()
    255                     }
    256                     return@launch
    257                 }
    258             }
    259         } else {
    260             actionFound.postValue(uri.toString())
    261         }
    262 
    263         return actionFound
    264     }
    265 
    266     private fun onRefundResponse(status: RefundStatus) {
    267         model.showProgressBar.value = false
    268         when (status) {
    269             is RefundStatus.Error -> {
    270                 if (model.devMode.value == true) {
    271                     showError(status.error)
    272                 } else {
    273                     showError(R.string.refund_error, status.error.userFacingMsg)
    274                 }
    275 
    276                 findNavController().navigateUp()
    277             }
    278             is RefundStatus.Success -> {
    279                 lifecycleScope.launch {
    280                     val transactionId = status.response.transactionId
    281                     val transaction = model.transactionManager.getTransactionById(transactionId)
    282                     if (transaction != null) {
    283                         // TODO: currency what? scopes are the cool thing now
    284                         // val currency = transaction.amountRaw.currency
    285                         // model.showTransactions(currency)
    286                         Snackbar.make(requireView(), getString(R.string.refund_success), LENGTH_LONG).show()
    287                     }
    288 
    289                     findNavController().navigateUp()
    290                 }
    291             }
    292 
    293         }
    294     }
    295 }