diff options
Diffstat (limited to 'wallet/src/main/java/net/taler')
4 files changed, 298 insertions, 213 deletions
diff --git a/wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt b/wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt new file mode 100644 index 0000000..e91d56d --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt @@ -0,0 +1,262 @@ +/* + * 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 <http://www.gnu.org/licenses/> + */ + +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 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.BaseTransientBottomBar +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() + + var uri: String? = null + var from: String? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + uri = arguments?.getString("uri") + from = arguments?.getString("from") + + 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!!) + } + + getTalerAction(uri, 3, MutableLiveData<String>()).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) + } + else -> { + showError(R.string.error_unsupported_uri, "From: $from\nURI: $u2") + findNavController().popBackStack() + } + } + } + } + + private fun getTalerAction( + uri: Uri, + maxRedirects: Int, + actionFound: MutableLiveData<String>, + ): MutableLiveData<String> { + 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) + } 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()) + } + } + // there's more than one entry point, so use global action + 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) { + val currency = transaction.amountRaw.currency + model.showTransactions(currency) + Snackbar.make(requireView(), getString(R.string.refund_success), + BaseTransientBottomBar.LENGTH_LONG + ).show() + } + } + } + } + } +}
\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/MainActivity.kt b/wallet/src/main/java/net/taler/wallet/MainActivity.kt index 80b26a5..7726abc 100644 --- a/wallet/src/main/java/net/taler/wallet/MainActivity.kt +++ b/wallet/src/main/java/net/taler/wallet/MainActivity.kt @@ -22,7 +22,6 @@ import android.content.Context import android.content.Intent import android.content.Intent.ACTION_VIEW import android.content.IntentFilter -import android.net.Uri import android.os.Bundle import android.util.Log import android.view.Menu @@ -35,10 +34,6 @@ import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf import androidx.core.view.GravityCompat.START -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.viewModelScope import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.AppBarConfiguration @@ -47,19 +42,12 @@ import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat.OnPreferenceStartFragmentCallback import com.google.android.material.navigation.NavigationView.OnNavigationItemSelectedListener -import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG -import com.google.android.material.snackbar.Snackbar import com.google.zxing.client.android.Intents.Scan.MIXED_SCAN import com.google.zxing.client.android.Intents.Scan.SCAN_TYPE import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions import com.journeyapps.barcodescanner.ScanOptions.QR_CODE -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import net.taler.common.EventObserver -import net.taler.common.isOnline -import net.taler.common.showError import net.taler.wallet.BuildConfig.VERSION_CODE import net.taler.wallet.BuildConfig.VERSION_NAME import net.taler.wallet.HostCardEmulatorService.Companion.HTTP_TUNNEL_RESPONSE @@ -68,11 +56,6 @@ import net.taler.wallet.HostCardEmulatorService.Companion.MERCHANT_NFC_DISCONNEC import net.taler.wallet.HostCardEmulatorService.Companion.TRIGGER_PAYMENT_ACTION import net.taler.wallet.databinding.ActivityMainBinding import net.taler.wallet.events.ObservabilityDialog -import net.taler.wallet.refund.RefundStatus -import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL -import java.util.Locale.ROOT class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, OnPreferenceStartFragmentCallback { @@ -182,15 +165,6 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, return true } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_show_logs -> { - ObservabilityDialog().show(supportFragmentManager, "OBSERVABILITY") - } - } - return super.onOptionsItemSelected(item) - } - override fun onDestroy() { unregisterReceiver(triggerPaymentReceiver) unregisterReceiver(nfcConnectedReceiver) @@ -199,186 +173,6 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, super.onDestroy() } - private fun getTalerAction( - uri: Uri, - maxRedirects: Int, - actionFound: MutableLiveData<String>, - ): MutableLiveData<String> { - 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") - } - } - } else { - actionFound.postValue(uri.toString()) - } - - return actionFound - } - - private fun handleTalerUri(url: String, from: String) { - val uri = Uri.parse(url) - if (uri.fragment != null && !isOnline()) { - connectToWifi(this, uri.fragment!!) - } - - getTalerAction(uri, 3, MutableLiveData<String>()).observe(this) { 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) - nav.navigate(R.id.action_nav_payto_uri, bundle) - return@observe - } - - val normalizedURL = u.lowercase(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!") - nav.navigate(R.id.action_global_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 - nav.navigate(R.id.action_global_promptWithdraw) - model.withdrawManager.getWithdrawalDetails(u2) - } - - action.startsWith("withdraw-exchange/", ignoreCase = true) -> { - model.showProgressBar.value = true - lifecycleScope.launch(Dispatchers.IO) { - val response = model.withdrawManager.prepareManualWithdrawal(u2) - if (response == null) withContext(Dispatchers.Main) { - model.showProgressBar.value = false - nav.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) - } 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()) - } - } - // there's more than one entry point, so use global action - nav.navigate(R.id.action_global_manual_withdrawal, args) - } - } - } - } - } - - action.startsWith("refund/", ignoreCase = true) -> { - model.showProgressBar.value = true - model.refundManager.refund(u2).observe(this, Observer(::onRefundResponse)) - } - action.startsWith("pay-pull/", ignoreCase = true) -> { - nav.navigate(R.id.action_global_prompt_pull_payment) - model.peerManager.preparePeerPullDebit(u2) - } - action.startsWith("pay-push/", ignoreCase = true) -> { - nav.navigate(R.id.action_global_prompt_push_payment) - model.peerManager.preparePeerPushCredit(u2) - } - action.startsWith("pay-template/", ignoreCase = true) -> { - val bundle = bundleOf("uri" to u2) - nav.navigate(R.id.action_global_prompt_pay_template, bundle) - } - else -> { - showError(R.string.error_unsupported_uri, "From: $from\nURI: $u2") - } - } - } - } - - 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) - } - } - 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(ui.navView, getString(R.string.refund_success), LENGTH_LONG).show() - } - } - } - } - } - private val triggerPaymentReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (nav.currentDestination?.id == R.id.promptPayment) return diff --git a/wallet/src/main/java/net/taler/wallet/compose/LoadingScreen.kt b/wallet/src/main/java/net/taler/wallet/compose/LoadingScreen.kt new file mode 100644 index 0000000..6412d63 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/compose/LoadingScreen.kt @@ -0,0 +1,34 @@ +/* + * 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 <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun LoadingScreen() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +}
\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt index b6c2fb1..3ea04cc 100644 --- a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt +++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt @@ -18,7 +18,6 @@ package net.taler.wallet.payment import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -30,6 +29,7 @@ import net.taler.common.Amount import net.taler.common.ContractTerms import net.taler.wallet.AmountResult import net.taler.wallet.R +import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface sealed class AmountFieldStatus { @@ -99,12 +99,7 @@ fun PayTemplateError(message: String) { @Composable fun PayTemplateLoading() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Center, - ) { - CircularProgressIndicator() - } + LoadingScreen() } @Preview |