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 }