/* * This file is part of GNU Taler * (C) 2024 Taler Systems S.A. * * GNU Taler is free software; you can redistribute it and/or modify it under the * terms of the GNU General Public License as published by the Free Software * Foundation; either version 3, or (at your option) any later version. * * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR * A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with * GNU Taler; see the file COPYING. If not, see */ package net.taler.wallet import android.net.Uri import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast.LENGTH_LONG import androidx.compose.ui.platform.ComposeView import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import androidx.navigation.fragment.findNavController import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.taler.common.isOnline import net.taler.common.showError import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.refund.RefundStatus import java.io.IOException import java.net.HttpURLConnection import java.net.URL import java.util.Locale class HandleUriFragment: Fragment() { private val model: MainViewModel by activityViewModels() lateinit var uri: String lateinit var from: String override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { uri = arguments?.getString("uri") ?: error("no uri passed") from = arguments?.getString("from") ?: error("no from passed") return ComposeView(requireContext()).apply { setContent { TalerSurface { LoadingScreen() } } } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val uri = Uri.parse(uri) if (uri.fragment != null && !requireContext().isOnline()) { connectToWifi(requireContext(), uri.fragment!!) } // TODO: fix this bad async programming, make it only async when needed. getTalerAction(uri, 3, MutableLiveData()).observe(viewLifecycleOwner) { u -> Log.v(TAG, "found action $u") if (u.startsWith("payto://", ignoreCase = true)) { Log.v(TAG, "navigating with paytoUri!") val bundle = bundleOf("uri" to u) findNavController().navigate(R.id.action_handleUri_to_nav_payto_uri, bundle) return@observe } val normalizedURL = u.lowercase(Locale.ROOT) var ext = false val action = normalizedURL.substring( if (normalizedURL.startsWith("taler://", ignoreCase = true)) { "taler://".length } else if (normalizedURL.startsWith("ext+taler://", ignoreCase = true)) { ext = true "ext+taler://".length } else if (normalizedURL.startsWith("taler+http://", ignoreCase = true) && model.devMode.value == true ) { "taler+http://".length } else { normalizedURL.length } ) // Remove ext+ scheme prefix if present val u2 = if (ext) { "taler://" + u.substring("ext+taler://".length) } else u when { action.startsWith("pay/", ignoreCase = true) -> { Log.v(TAG, "navigating!") findNavController().navigate(R.id.action_handleUri_to_promptPayment) model.paymentManager.preparePay(u2) } action.startsWith("withdraw/", ignoreCase = true) -> { Log.v(TAG, "navigating!") // there's more than one entry point, so use global action findNavController().navigate(R.id.action_handleUri_to_promptWithdraw) model.withdrawManager.getWithdrawalDetails(u2) } action.startsWith("withdraw-exchange/", ignoreCase = true) -> { prepareManualWithdrawal(u2) } action.startsWith("refund/", ignoreCase = true) -> { model.showProgressBar.value = true model.refundManager.refund(u2).observe(viewLifecycleOwner, Observer(::onRefundResponse)) } action.startsWith("pay-pull/", ignoreCase = true) -> { findNavController().navigate(R.id.action_handleUri_to_promptPullPayment) model.peerManager.preparePeerPullDebit(u2) } action.startsWith("pay-push/", ignoreCase = true) -> { findNavController().navigate(R.id.action_handleUri_to_promptPushPayment) model.peerManager.preparePeerPushCredit(u2) } action.startsWith("pay-template/", ignoreCase = true) -> { val bundle = bundleOf("uri" to u2) findNavController().navigate(R.id.action_handleUri_to_promptPayTemplate, bundle) } action.startsWith("dev-experiment/", ignoreCase = true) -> { model.applyDevExperiment(u2) { error -> showError(error) } findNavController().navigate(R.id.nav_main) } else -> { showError(R.string.error_unsupported_uri, "From: $from\nURI: $u2") findNavController().popBackStack() } } } } private fun getTalerAction( uri: Uri, maxRedirects: Int, actionFound: MutableLiveData, ): MutableLiveData { val scheme = uri.scheme ?: return actionFound if (scheme == "http" || scheme == "https") { model.viewModelScope.launch(Dispatchers.IO) { val conn = URL(uri.toString()).openConnection() as HttpURLConnection Log.v(TAG, "prepare query: $uri") conn.setRequestProperty("Accept", "text/html") conn.connectTimeout = 5000 conn.requestMethod = "HEAD" try { conn.connect() } catch (e: IOException) { Log.e(TAG, "Error connecting to $uri ", e) showError(R.string.error_broken_uri, "$uri") return@launch } val status = conn.responseCode if (status == HttpURLConnection.HTTP_OK || status == HttpURLConnection.HTTP_PAYMENT_REQUIRED) { val talerHeader = conn.headerFields["Taler"] if (talerHeader != null && talerHeader[0] != null) { Log.v(TAG, "taler header: ${talerHeader[0]}") val talerHeaderUri = Uri.parse(talerHeader[0]) getTalerAction(talerHeaderUri, 0, actionFound) } } else if (status == HttpURLConnection.HTTP_MOVED_TEMP || status == HttpURLConnection.HTTP_MOVED_PERM || status == HttpURLConnection.HTTP_SEE_OTHER ) { val location = conn.headerFields["Location"] if (location != null && location[0] != null) { Log.v(TAG, "location redirect: ${location[0]}") val locUri = Uri.parse(location[0]) getTalerAction(locUri, maxRedirects - 1, actionFound) } } else { showError(R.string.error_broken_uri, "$uri") findNavController().popBackStack() } } } else { actionFound.postValue(uri.toString()) } return actionFound } private fun prepareManualWithdrawal(uri: String) { model.showProgressBar.value = true lifecycleScope.launch(Dispatchers.IO) { val response = model.withdrawManager.prepareManualWithdrawal(uri) if (response == null) withContext(Dispatchers.Main) { model.showProgressBar.value = false findNavController().navigate(R.id.errorFragment) } else { val exchange = model.exchangeManager.findExchangeByUrl(response.exchangeBaseUrl) if (exchange == null) withContext(Dispatchers.Main) { model.showProgressBar.value = false showError(R.string.exchange_add_error) findNavController().navigateUp() } else { model.exchangeManager.withdrawalExchange = exchange withContext(Dispatchers.Main) { model.showProgressBar.value = false val args = Bundle().apply { if (response.amount != null) { putString("amount", response.amount.toJSONString()) } } findNavController().navigate(R.id.action_handleUri_to_manualWithdrawal, args) } } } } } private fun onRefundResponse(status: RefundStatus) { model.showProgressBar.value = false when (status) { is RefundStatus.Error -> { if (model.devMode.value == true) { showError(status.error) } else { showError(R.string.refund_error, status.error.userFacingMsg) } findNavController().navigateUp() } is RefundStatus.Success -> { lifecycleScope.launch { val transactionId = status.response.transactionId val transaction = model.transactionManager.getTransactionById(transactionId) if (transaction != null) { // TODO: currency what? scopes are the cool thing now // val currency = transaction.amountRaw.currency // model.showTransactions(currency) Snackbar.make(requireView(), getString(R.string.refund_success), LENGTH_LONG).show() } findNavController().navigateUp() } } } } }