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 }