taler-android

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

HandleUriScreen.kt (8951B)


      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
     18 
     19 import android.net.Uri
     20 import android.util.Log
     21 import androidx.compose.foundation.layout.Box
     22 import androidx.compose.foundation.layout.fillMaxSize
     23 import androidx.compose.runtime.Composable
     24 import androidx.compose.runtime.LaunchedEffect
     25 import androidx.compose.runtime.getValue
     26 import androidx.compose.runtime.livedata.observeAsState
     27 import androidx.compose.runtime.mutableStateOf
     28 import androidx.compose.runtime.remember
     29 import androidx.compose.runtime.rememberCoroutineScope
     30 import androidx.compose.runtime.setValue
     31 import androidx.compose.ui.Modifier
     32 import androidx.core.net.toUri
     33 import androidx.lifecycle.MutableLiveData
     34 import androidx.lifecycle.viewModelScope
     35 import kotlinx.coroutines.Dispatchers
     36 import kotlinx.coroutines.launch
     37 import net.taler.wallet.backend.TalerErrorInfo
     38 import net.taler.wallet.compose.LoadingScreen
     39 import net.taler.wallet.compose.RetryScreen
     40 import net.taler.wallet.main.MainViewModel
     41 import net.taler.wallet.main.TAG
     42 import net.taler.wallet.refund.RefundStatus
     43 import java.io.IOException
     44 import java.net.HttpURLConnection
     45 import java.net.URL
     46 import java.util.Locale
     47 
     48 @Composable
     49 fun HandleUriScreen(
     50     model: MainViewModel,
     51     uriString: String,
     52     onNavigate: NavigateCallback,
     53     onNavigateBack: () -> Unit,
     54     onShowError: (error: TalerErrorInfo) -> Unit,
     55 ) {
     56     var processing by remember { mutableStateOf(false) }
     57     var errorInfo by remember { mutableStateOf<TalerErrorInfo?>(null) }
     58     val networkStatus by model.networkManager.networkStatus.observeAsState()
     59     val devMode by model.devMode.observeAsState(false)
     60     val scope = rememberCoroutineScope()
     61 
     62     fun processTalerUri() {
     63         if (processing) return
     64         processing = true
     65 
     66         val uri = uriString.trim().toUri()
     67         // wifi connection logic omitted for now as it uses requireContext()
     68 
     69         getTalerAction(model, uri, 3, MutableLiveData()).observeForever { u ->
     70             Log.v(TAG, "found action $u")
     71 
     72             if (u.startsWith("payto://", ignoreCase = true)) {
     73                 onNavigate(WalletDestination.PaytoUri(u), true)
     74                 return@observeForever
     75             }
     76 
     77             val normalizedURL = u.lowercase(Locale.ROOT)
     78             var ext = false
     79             val action = normalizedURL.substring(
     80                 if (normalizedURL.startsWith("taler://", ignoreCase = true)) {
     81                     "taler://".length
     82                 } else if (normalizedURL.startsWith("ext+taler://", ignoreCase = true)) {
     83                     ext = true
     84                     "ext+taler://".length
     85                 } else if (normalizedURL.startsWith("taler+http://", ignoreCase = true) &&
     86                     model.devMode.value == true
     87                 ) {
     88                     "taler+http://".length
     89                 } else {
     90                     normalizedURL.length
     91                 }
     92             )
     93 
     94             val u2 = if (ext) {
     95                 "taler://" + u.substring("ext+taler://".length)
     96             } else u
     97 
     98             when {
     99                 action.startsWith("pay/", ignoreCase = true) -> {
    100                     scope.launch {
    101                         model.paymentManager.preparePay(u2)?.let { transactionId ->
    102                             if (model.transactionManager.selectTransaction(transactionId)) {
    103                                 onNavigate(WalletDestination.TransactionPayment, true)
    104                             }
    105                         }
    106                     }
    107                 }
    108                 action.startsWith("withdraw/", ignoreCase = true) -> {
    109                     model.withdrawManager.resetWithdrawal()
    110                     onNavigate(WalletDestination.PromptWithdraw(
    111                         withdrawUri = u2,
    112                         editableCurrency = false
    113                     ), true)
    114                 }
    115                 action.startsWith("withdraw-exchange/", ignoreCase = true) -> {
    116                     model.withdrawManager.resetWithdrawal()
    117                     onNavigate(WalletDestination.PromptWithdraw(
    118                         withdrawExchangeUri = u2,
    119                         editableCurrency = false
    120                     ), true)
    121                 }
    122                 action.startsWith("refund/", ignoreCase = true) -> {
    123                     model.showProgressBar.value = true
    124                     model.refundManager.refund(u2).observeForever { status ->
    125                         model.showProgressBar.value = false
    126                         when (status) {
    127                             is RefundStatus.Error -> {
    128                                 errorInfo = status.error
    129                             }
    130                             is RefundStatus.Success -> {
    131                                 onNavigateBack()
    132                             }
    133                         }
    134                     }
    135                 }
    136                 action.startsWith("pay-pull/", ignoreCase = true) -> {
    137                     model.peerManager.preparePeerPullDebit(u2)
    138                     onNavigate(WalletDestination.PromptPullPayment, true)
    139                 }
    140                 action.startsWith("pay-push/", ignoreCase = true) -> {
    141                     model.peerManager.preparePeerPushCredit(u2)
    142                     onNavigate(WalletDestination.PromptPushPayment, true)
    143                 }
    144                 action.startsWith("pay-template/", ignoreCase = true) -> {
    145                     onNavigate(WalletDestination.PromptPayTemplate(u2), true)
    146                 }
    147                 action.startsWith("dev-experiment/", ignoreCase = true) -> {
    148                     model.applyDevExperiment(u2) { error ->
    149                         errorInfo = error
    150                     }
    151                     onNavigateBack()
    152                 }
    153                 else -> {
    154                     errorInfo = TalerErrorInfo.makeCustomError("Unsupported URI: $u2")
    155                 }
    156             }
    157         }
    158     }
    159 
    160     LaunchedEffect(networkStatus) {
    161         if (networkStatus == true) {
    162             processTalerUri()
    163         }
    164     }
    165 
    166     LaunchedEffect(errorInfo) {
    167         val currentError = errorInfo
    168         if (currentError != null) {
    169             onShowError(currentError)
    170             onNavigateBack()
    171         }
    172     }
    173 
    174     Box(Modifier.fillMaxSize()) {
    175         if (networkStatus == true) {
    176             LoadingScreen()
    177         } else {
    178             RetryScreen {
    179                 processTalerUri()
    180             }
    181         }
    182     }
    183 }
    184 
    185 private fun getTalerAction(
    186     model: MainViewModel,
    187     uri: Uri,
    188     maxRedirects: Int,
    189     actionFound: MutableLiveData<String>,
    190 ): MutableLiveData<String> {
    191     val scheme = uri.scheme ?: return actionFound
    192 
    193     if (scheme == "http" || scheme == "https") {
    194         model.viewModelScope.launch(Dispatchers.IO) {
    195             try {
    196                 val conn = URL(uri.toString()).openConnection() as HttpURLConnection
    197                 conn.setRequestProperty("Accept", "text/html")
    198                 conn.connectTimeout = 5000
    199                 conn.requestMethod = "HEAD"
    200                 conn.connect()
    201                 val status = conn.responseCode
    202 
    203                 if (status == HttpURLConnection.HTTP_OK || status == HttpURLConnection.HTTP_PAYMENT_REQUIRED) {
    204                     val talerHeader = conn.headerFields["Taler"]
    205                     if (talerHeader != null && talerHeader[0] != null) {
    206                         val talerHeaderUri = talerHeader[0].toUri()
    207                         getTalerAction(model, talerHeaderUri, 0, actionFound)
    208                     } else {
    209                         // Error handling omitted for brevity
    210                     }
    211                 } else if (status == HttpURLConnection.HTTP_MOVED_TEMP
    212                     || status == HttpURLConnection.HTTP_MOVED_PERM
    213                     || status == HttpURLConnection.HTTP_SEE_OTHER
    214                 ) {
    215                     val location = conn.headerFields["Location"]
    216                     if (location != null && location[0] != null) {
    217                         val locUri = location[0].toUri()
    218                         getTalerAction(model, locUri, maxRedirects - 1, actionFound)
    219                     }
    220                 }
    221             } catch (e: IOException) {
    222                 // Error handling omitted
    223             }
    224         }
    225     } else {
    226         actionFound.postValue(uri.toString())
    227     }
    228 
    229     return actionFound
    230 }