diff options
43 files changed, 1286 insertions, 395 deletions
diff --git a/cashier/src/main/res/values-ru/strings.xml b/cashier/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..377f3f7 --- /dev/null +++ b/cashier/src/main/res/values-ru/strings.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="app_name">Taler Кассир</string> + <string name="config_bank_url">Адресс API банка</string> + <string name="config_username">Имя пользователя</string> + <string name="config_password">Пароль</string> + <string name="config_button_save">Сохранить</string> + <string name="config_error_auth">Неправильное имя пользователя или пароль!</string> + <string name="config_demo_hint">Для тестирования, вы можете <a href=%s>создать тестовую учётную запист в демонстрационном банке</a>.</string> + <string name="balance_current_label">Теущий баланс</string> + <string name="balance_error">ОШИБКА: %s</string> + <string name="balance_offline">Нет связи. Пожалуйста подключитесь к интернету.</string> + <string name="action_reconfigure">Переконфигурировать</string> + <string name="action_lock">Заблокировать</string> + <string name="action_about">О программе</string> + <string name="withdraw_input_amount">Количество</string> + <string name="ok">OK</string> + <string name="config_bank_url_error">Адрес неправильный.</string> + <string name="config_username_error">Пожалуйста введите ваше имя пользователя!</string> + <string name="config_error">Ошибка при получении конфигурации</string> + <string name="withdraw_into">Сколько e-cash должно быть списано?</string> + <string name="withdraw_error_zero">Введите позитивное количество!</string> + <string name="withdraw_error_insufficient_balance">Недостаточный баланс</string> + <string name="withdraw_error_currency_mismatch">Ошибка: Банк использует другую валюту</string> + <string name="withdraw_error_fetch">Ошибка при связи с банком: %s</string> + <string name="withdraw_error_timeout">Никакой кошелёк не попробоавл списать. Пожалуйста попробуйте ещё раз.</string> + <string name="withdraw_button_confirm">Списание</string> + <string name="transaction_intro">Отсканируйте код приложением Кошелька Taler чтобы списать:</string> + <string name="transaction_intro_nfc">Отсканируйте код или используйте NFC в приложении Кошелька Taler чтобы списать:</string> + <string name="transaction_intro_scanned">Пожалуйста подтвердите транзакцию!</string> + <string name="transaction_confirm">Подтвердить</string> + <string name="transaction_abort">Подробности</string> + <string name="transaction_aborted">Транзакция прервана</string> + <string name="transaction_button_back">Перейти назад</string> + <string name="transaction_last_success">Последняя Транзакция: %s списано</string> + <string name="transaction_last_aborted">Последняя Транзакция: Прервана</string> + <string name="transaction_last_error">Последняя Транзакция: Неуспешна</string> + <string name="about_title">Кассир GNU Taler</string> + <string name="about_version">Версия: %s</string> + <string name="about_license">Лицензия: %s</string> + <string name="about_copyright">Авторские права: %s</string> + <string name="about_supported_bank_api">Версия API банка: %s</string> +</resources>
\ No newline at end of file diff --git a/import-svg-assets.sh b/import-svg-assets.sh new file mode 100755 index 0000000..19f0923 --- /dev/null +++ b/import-svg-assets.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# +# 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/> +# + +# To get the s2v command, do this: +# +# $ npm install svg2vectordrawable -g +# + +ASSETS_DIR="../taler-assets/svg" +TMP_DIR="../taler-assets/s2v-tmp" +OUTPUT_DIR="./wallet/src/main/res/drawable/" + +set -ex + +mkdir -p "$TMP_DIR" + +for d in "$ASSETS_DIR"/*; do + s2v --folder="$d" --output="$TMP_DIR" --tint="?attr/colorControlNormal" +done + +# remove unneeded icons +rm "$TMP_DIR/taler_logo_2021_plain.xml" +rm "$TMP_DIR/taler_logo_2022.xml" +rm "$TMP_DIR/logo_2021.xml" + +# add tint +sed -i 's@android:viewportWidth@android:tint="?attr/colorControlNormal"\n android:viewportWidth@g' "$TMP_DIR"/*.xml +# reduce size +sed -i 's@"1200dp"@"24dp"@g' "$TMP_DIR"/*.xml +# add path fillColor +sed -i 's@<path@<path\n android:fillColor="#FF000000"@g' "$TMP_DIR"/*.xml + +# move final files +mv "$TMP_DIR"/*.xml "$OUTPUT_DIR" + +# remove tmp dir +rm -rf "$TMP_DIR" diff --git a/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt b/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt index 88e90b0..5b614fe 100644 --- a/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt +++ b/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt @@ -64,7 +64,7 @@ data class ContractProduct( @SerialName("delivery_location") override val location: String? = null, override val image: String? = null, - val quantity: Int + val quantity: Int = 1, ) : Product() { val totalPrice: Amount? by lazy { price?.let { price * quantity } diff --git a/wallet/build.gradle b/wallet/build.gradle index 62a7782..93f4a49 100644 --- a/wallet/build.gradle +++ b/wallet/build.gradle @@ -19,7 +19,7 @@ plugins { id "kotlinx-serialization" } -def qtart_version = "0.9.4-dev.18" +def qtart_version = "0.10.6" static def versionCodeEpoch() { return (new Date().getTime() / 1000).toInteger() @@ -41,8 +41,8 @@ android { applicationId "net.taler.wallet" minSdkVersion 24 targetSdkVersion 33 - versionCode 39 - versionName "0.9.4+p2" + versionCode 41 + versionName "0.10.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { 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..6b8db78 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt @@ -0,0 +1,272 @@ +/* + * 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 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<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) + } + 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<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) + 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() + } + } + + } + } +}
\ 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 5dfd920..00fd2d3 100644 --- a/wallet/src/main/java/net/taler/wallet/MainActivity.kt +++ b/wallet/src/main/java/net/taler/wallet/MainActivity.kt @@ -22,9 +22,9 @@ 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 import android.view.MenuItem import android.view.View.GONE import android.view.View.INVISIBLE @@ -34,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 @@ -46,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 @@ -66,11 +55,7 @@ import net.taler.wallet.HostCardEmulatorService.Companion.MERCHANT_NFC_CONNECTED import net.taler.wallet.HostCardEmulatorService.Companion.MERCHANT_NFC_DISCONNECTED import net.taler.wallet.HostCardEmulatorService.Companion.TRIGGER_PAYMENT_ACTION import net.taler.wallet.databinding.ActivityMainBinding -import net.taler.wallet.refund.RefundStatus -import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL -import java.util.Locale.ROOT +import net.taler.wallet.events.ObservabilityDialog class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, OnPreferenceStartFragmentCallback { @@ -144,6 +129,10 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, model.networkManager.networkStatus.observe(this) { online -> ui.content.offlineBanner.visibility = if (online) GONE else VISIBLE } + + model.devMode.observe(this) { + invalidateMenu() + } } @Deprecated("Deprecated in Java") @@ -159,6 +148,14 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, } } + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + if (model.devMode.value == true) { + menuInflater.inflate(R.menu.global_dev, menu) + } + + return super.onCreateOptionsMenu(menu) + } + override fun onNavigationItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.nav_home -> nav.navigate(R.id.nav_main) @@ -168,192 +165,26 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, return true } - override fun onDestroy() { - unregisterReceiver(triggerPaymentReceiver) - unregisterReceiver(nfcConnectedReceiver) - unregisterReceiver(nfcDisconnectedReceiver) - unregisterReceiver(tunnelResponseReceiver) - 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") - } + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_show_logs -> { + ObservabilityDialog().show(supportFragmentManager, "OBSERVABILITY") } - } else { - actionFound.postValue(uri.toString()) } - - return actionFound + return super.onOptionsItemSelected(item) } - 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 handleTalerUri(uri: String, from: String) { + val args = bundleOf("uri" to uri, "from" to from) + nav.navigate(R.id.action_global_handle_uri, 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) - } - } - 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() - } - } - } - } + override fun onDestroy() { + unregisterReceiver(triggerPaymentReceiver) + unregisterReceiver(nfcConnectedReceiver) + unregisterReceiver(nfcDisconnectedReceiver) + unregisterReceiver(tunnelResponseReceiver) + super.onDestroy() } private val triggerPaymentReceiver = object : BroadcastReceiver() { diff --git a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt index 5903446..82eb8d7 100644 --- a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt +++ b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt @@ -24,20 +24,29 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString import net.taler.common.Amount import net.taler.common.AmountParserException import net.taler.common.Event import net.taler.common.toEvent import net.taler.wallet.accounts.AccountManager +import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.NotificationPayload import net.taler.wallet.backend.NotificationReceiver +import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.VersionReceiver import net.taler.wallet.backend.WalletBackendApi import net.taler.wallet.backend.WalletCoreVersion +import net.taler.wallet.backend.WalletRunConfig +import net.taler.wallet.backend.WalletRunConfig.Testing import net.taler.wallet.balances.BalanceManager import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.deposit.DepositManager +import net.taler.wallet.events.ObservabilityEvent import net.taler.wallet.exchanges.ExchangeManager import net.taler.wallet.payment.PaymentManager import net.taler.wallet.peer.PeerManager @@ -48,16 +57,24 @@ import net.taler.wallet.withdraw.WithdrawManager import org.json.JSONObject const val TAG = "taler-wallet" +const val OBSERVABILITY_LIMIT = 100 private val transactionNotifications = listOf( "transaction-state-transition", ) +private val observabilityNotifications = listOf( + "task-observability-event", + "request-observability-event", +) + class MainViewModel( app: Application, ) : AndroidViewModel(app), VersionReceiver, NotificationReceiver { - val devMode = MutableLiveData(BuildConfig.DEBUG) + private val mDevMode = MutableLiveData(BuildConfig.DEBUG) + val devMode: LiveData<Boolean> = mDevMode + val showProgressBar = MutableLiveData<Boolean>() var walletVersion: String? = null private set @@ -68,7 +85,15 @@ class MainViewModel( var merchantVersion: String? = null private set - private val api = WalletBackendApi(app, this, this) + @set:Synchronized + private var walletConfig = WalletRunConfig( + testing = Testing( + emitObservabilityEvents = true, + devModeActive = devMode.value ?: false, + ) + ) + + private val api = WalletBackendApi(app, walletConfig, this, this) val networkManager = NetworkManager(app.applicationContext) val withdrawManager = WithdrawManager(api, viewModelScope) @@ -85,6 +110,9 @@ class MainViewModel( private val mTransactionsEvent = MutableLiveData<Event<ScopeInfo>>() val transactionsEvent: LiveData<Event<ScopeInfo>> = mTransactionsEvent + private val mObservabilityLog = MutableStateFlow<List<ObservabilityEvent>>(emptyList()) + val observabilityLog: StateFlow<List<ObservabilityEvent>> = mObservabilityLog + private val mScanCodeEvent = MutableLiveData<Event<Boolean>>() val scanCodeEvent: LiveData<Event<Boolean>> = mScanCodeEvent @@ -97,13 +125,24 @@ class MainViewModel( override fun onNotificationReceived(payload: NotificationPayload) { if (payload.type == "waiting-for-retry") return // ignore ping) - Log.i(TAG, "Received notification from wallet-core: $payload") + + val str = BackendManager.json.encodeToString(payload) + Log.i(TAG, "Received notification from wallet-core: $str") // Only update balances when we're told they changed if (payload.type == "balance-change") viewModelScope.launch(Dispatchers.Main) { balanceManager.loadBalances() } + if (payload.type in observabilityNotifications && payload.event != null) { + mObservabilityLog.getAndUpdate { logs -> + logs.takeLast(OBSERVABILITY_LIMIT) + .toMutableList().apply { + add(payload.event) + } + } + } + if (payload.type in transactionNotifications) viewModelScope.launch(Dispatchers.Main) { // TODO notification API should give us a currency to update // update currently selected transaction list @@ -174,6 +213,24 @@ class MainViewModel( mScanCodeEvent.value = true.toEvent() } + fun setDevMode(enabled: Boolean, onError: (error: TalerErrorInfo) -> Unit) { + mDevMode.postValue(enabled) + viewModelScope.launch { + val config = walletConfig.copy( + testing = walletConfig.testing?.copy( + devModeActive = enabled, + ) ?: Testing( + devModeActive = enabled, + ), + ) + + api.setWalletConfig(config) + .onSuccess { + walletConfig = config + }.onError(onError) + } + } + fun runIntegrationTest() { viewModelScope.launch { api.request<Unit>("runIntegrationTestV2") { @@ -187,6 +244,14 @@ class MainViewModel( } } + fun applyDevExperiment(uri: String, onError: (error: TalerErrorInfo) -> Unit) { + viewModelScope.launch { + api.request<Unit>("applyDevExperiment") { + put("devExperimentUri", uri) + }.onError(onError) + } + } + } sealed class AmountResult { diff --git a/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt b/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt index 85e2340..2accaaf 100644 --- a/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt @@ -50,10 +50,8 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import kotlinx.serialization.encodeToString import net.taler.common.Amount import net.taler.common.CurrencySpecification -import net.taler.wallet.backend.BackendManager import net.taler.wallet.compose.AmountInputField import net.taler.wallet.compose.DEFAULT_INPUT_DECIMALS import net.taler.wallet.compose.TalerSurface @@ -111,10 +109,7 @@ class ReceiveFundsFragment : Fragment() { } private fun onPeerPull(amount: Amount) { - val bundle = bundleOf( - "amount" to amount.toJSONString(), - "scopeInfo" to BackendManager.json.encodeToString(scopeInfo), - ) + val bundle = bundleOf("amount" to amount.toJSONString()) peerManager.checkPeerPullCredit(amount) findNavController().navigate(R.id.action_receiveFunds_to_nav_peer_pull, bundle) } @@ -176,7 +171,7 @@ private fun ReceiveFundsIntro( .weight(1f), onClick = { val amount = getAmount(currency, text) - if (amount == null) isError = true + if (amount == null || amount.isZero()) isError = true else onManualWithdraw(amount) }) { Text(text = stringResource(R.string.receive_withdraw)) diff --git a/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt b/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt index a26361b..2581979 100644 --- a/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt @@ -47,10 +47,8 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController -import kotlinx.serialization.encodeToString import net.taler.common.Amount import net.taler.common.CurrencySpecification -import net.taler.wallet.backend.BackendManager import net.taler.wallet.compose.AmountInputField import net.taler.wallet.compose.DEFAULT_INPUT_DECIMALS import net.taler.wallet.compose.TalerSurface @@ -85,18 +83,12 @@ class SendFundsFragment : Fragment() { } private fun onDeposit(amount: Amount) { - val bundle = bundleOf( - "amount" to amount.toJSONString(), - "scopeInfo" to BackendManager.json.encodeToString(scopeInfo), - ) + val bundle = bundleOf("amount" to amount.toJSONString()) findNavController().navigate(R.id.action_sendFunds_to_nav_deposit, bundle) } private fun onPeerPush(amount: Amount) { - val bundle = bundleOf( - "amount" to amount.toJSONString(), - "scopeInfo" to BackendManager.json.encodeToString(scopeInfo), - ) + val bundle = bundleOf("amount" to amount.toJSONString()) peerManager.checkPeerPushDebit(amount) findNavController().navigate(R.id.action_sendFunds_to_nav_peer_push, bundle) } diff --git a/wallet/src/main/java/net/taler/wallet/Utils.kt b/wallet/src/main/java/net/taler/wallet/Utils.kt index 8b34531..5c4fedc 100644 --- a/wallet/src/main/java/net/taler/wallet/Utils.kt +++ b/wallet/src/main/java/net/taler/wallet/Utils.kt @@ -139,3 +139,9 @@ fun FragmentActivity.showError(error: TalerErrorInfo) { val message = json.encodeToString(error) showError(message) } + +fun Context.getThemeColor(attr: Int): Int { + val typedValue = TypedValue() + theme.resolveAttribute(attr, typedValue, true) + return typedValue.data +}
\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/backend/ApiResponse.kt b/wallet/src/main/java/net/taler/wallet/backend/ApiResponse.kt index 46eb2f0..def4668 100644 --- a/wallet/src/main/java/net/taler/wallet/backend/ApiResponse.kt +++ b/wallet/src/main/java/net/taler/wallet/backend/ApiResponse.kt @@ -19,6 +19,7 @@ package net.taler.wallet.backend import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject +import net.taler.wallet.events.ObservabilityEvent @Serializable sealed class ApiMessage { @@ -35,6 +36,7 @@ sealed class ApiMessage { data class NotificationPayload( val type: String, val id: String? = null, + val event: ObservabilityEvent? = null, ) @Serializable diff --git a/wallet/src/main/java/net/taler/wallet/backend/BackendManager.kt b/wallet/src/main/java/net/taler/wallet/backend/BackendManager.kt index b2f1f10..9292ef5 100644 --- a/wallet/src/main/java/net/taler/wallet/backend/BackendManager.kt +++ b/wallet/src/main/java/net/taler/wallet/backend/BackendManager.kt @@ -17,7 +17,6 @@ package net.taler.wallet.backend import android.util.Log -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import net.taler.qtart.TalerWalletCore import net.taler.wallet.BuildConfig @@ -40,6 +39,7 @@ class BackendManager( private const val TAG_CORE = "taler-wallet-embedded" val json = Json { ignoreUnknownKeys = true + coerceInputValues = true } @JvmStatic private val initialized = AtomicBoolean(false) @@ -52,6 +52,7 @@ class BackendManager( // TODO using Dagger/Hilt and @Singleton would be nice as well if (initialized.getAndSet(true)) error("Already initialized") walletCore.setMessageHandler { onMessageReceived(it) } + walletCore.setCurlHttpClient() if (BuildConfig.DEBUG) walletCore.setStdoutHandler { Log.d(TAG_CORE, it) } diff --git a/wallet/src/main/java/net/taler/wallet/backend/InitResponse.kt b/wallet/src/main/java/net/taler/wallet/backend/InitResponse.kt index e9f7fcd..7fe1a6b 100644 --- a/wallet/src/main/java/net/taler/wallet/backend/InitResponse.kt +++ b/wallet/src/main/java/net/taler/wallet/backend/InitResponse.kt @@ -17,12 +17,55 @@ package net.taler.wallet.backend import kotlinx.serialization.Serializable +import net.taler.wallet.exchanges.BuiltinExchange @Serializable data class InitResponse( val versionInfo: WalletCoreVersion, ) +@Serializable +data class WalletRunConfig( + val builtin: Builtin? = Builtin(), + val testing: Testing? = Testing(), + val features: Features? = Features(), +) { + /** + * Initialization values useful for a complete startup. + * + * These are values may be overridden by different wallets + */ + @Serializable + data class Builtin( + val exchanges: List<BuiltinExchange> = emptyList(), + ) + + /** + * Unsafe options which it should only be used to create + * testing environment. + */ + @Serializable + data class Testing( + /** + * Allow withdrawal of denominations even though they are about to expire. + */ + val denomselAllowLate: Boolean = false, + val devModeActive: Boolean = false, + val insecureTrustExchange: Boolean = false, + val preventThrottling: Boolean = false, + val skipDefaults: Boolean = false, + val emitObservabilityEvents: Boolean? = false, + ) + + /** + * Configurations values that may be safe to show to the user + */ + @Serializable + data class Features( + val allowHttp: Boolean = false, + ) +} + fun interface VersionReceiver { fun onVersionReceived(versionInfo: WalletCoreVersion) } diff --git a/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt b/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt index 4e179bb..fba9885 100644 --- a/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt +++ b/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.KSerializer +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import net.taler.wallet.backend.TalerErrorCode.NONE @@ -34,6 +35,7 @@ private const val WALLET_DB = "talerwalletdb.sqlite3" @OptIn(DelicateCoroutinesApi::class) class WalletBackendApi( private val app: Application, + private val initialConfig: WalletRunConfig, private val versionReceiver: VersionReceiver, notificationReceiver: NotificationReceiver, ) { @@ -54,9 +56,11 @@ class WalletBackendApi( } else { "${app.filesDir}/${WALLET_DB}" } + request("init", InitResponse.serializer()) { put("persistentStoragePath", db) put("logLevel", "INFO") + put("config", JSONObject(BackendManager.json.encodeToString(initialConfig))) }.onSuccess { response -> versionReceiver.onVersionReceived(response.versionInfo) }.onError { error -> @@ -65,6 +69,12 @@ class WalletBackendApi( } } + suspend fun setWalletConfig(config: WalletRunConfig): WalletResponse<InitResponse> { + return request("initWallet", InitResponse.serializer()) { + put("config", JSONObject(BackendManager.json.encodeToString(config))) + } + } + suspend fun sendRequest(operation: String, args: JSONObject? = null): ApiResponse { return backendManager.send(operation, args) } 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/deposit/DepositFragment.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt index 846afb9..20acee1 100644 --- a/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt @@ -30,8 +30,6 @@ import net.taler.common.showError import net.taler.wallet.CURRENCY_BTC import net.taler.wallet.MainViewModel import net.taler.wallet.R -import net.taler.wallet.backend.BackendManager -import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.showError @@ -40,6 +38,7 @@ class DepositFragment : Fragment() { private val model: MainViewModel by activityViewModels() private val depositManager get() = model.depositManager private val balanceManager get() = model.balanceManager + private val transactionManager get() = model.transactionManager override fun onCreateView( inflater: LayoutInflater, @@ -49,9 +48,7 @@ class DepositFragment : Fragment() { val amount = arguments?.getString("amount")?.let { Amount.fromJSONString(it) } ?: error("no amount passed") - val scopeInfo: ScopeInfo? = arguments?.getString("scopeInfo")?.let { - BackendManager.json.decodeFromString(it) - } + val scopeInfo = transactionManager.selectedScope val spec = scopeInfo?.let { balanceManager.getSpecForScopeInfo(it) } val receiverName = arguments?.getString("receiverName") val iban = arguments?.getString("IBAN") diff --git a/wallet/src/main/java/net/taler/wallet/events/ObservabilityDialog.kt b/wallet/src/main/java/net/taler/wallet/events/ObservabilityDialog.kt new file mode 100644 index 0000000..0ce5c01 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/events/ObservabilityDialog.kt @@ -0,0 +1,163 @@ +/* + * 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.events + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import net.taler.wallet.MainViewModel +import net.taler.wallet.R +import net.taler.wallet.compose.CopyToClipboardButton +import net.taler.wallet.events.ObservabilityDialog.Companion.json +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +class ObservabilityDialog: DialogFragment() { + private val model: MainViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = ComposeView(requireContext()).apply { + setContent { + val events by model.observabilityLog.collectAsState() + ObservabilityComposable(events.reversed()) { + dismiss() + } + } + } + + companion object { + @OptIn(ExperimentalSerializationApi::class) + val json = Json { + prettyPrint = true + prettyPrintIndent = " " + } + } +} + +@Composable +fun ObservabilityComposable( + events: List<ObservabilityEvent>, + onDismiss: () -> Unit, +) { + var showJson by remember { mutableStateOf(false) } + + AlertDialog( + title = { Text(stringResource(R.string.observability_title)) }, + text = { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(events) { event -> + ObservabilityItem(event, showJson) + } + } + }, + onDismissRequest = onDismiss, + dismissButton = { + Button(onClick = { showJson = !showJson }) { + Text(if (showJson) { + stringResource(R.string.observability_hide_json) + } else { + stringResource(R.string.observability_show_json) + }) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.close)) + } + }, + ) +} + +@Composable +fun ObservabilityItem( + event: ObservabilityEvent, + showJson: Boolean, +) { + val body = json.encodeToString(event.body) + val timestamp = DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.MEDIUM) + .format(event.timestamp) + + ListItem( + modifier = Modifier.fillMaxWidth(), + headlineContent = { Text(event.type) }, + overlineContent = { Text(timestamp) }, + supportingContent = if (!showJson) null else { -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier.background( + MaterialTheme.colorScheme.secondaryContainer, + shape = MaterialTheme.shapes.small, + ) + ) { + Text( + modifier = Modifier + .padding(10.dp) + .fillMaxWidth(), + text = body, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + ) + } + + CopyToClipboardButton( + label = "Event", + content = body, + colors = ButtonDefaults.textButtonColors(), + ) + } + }, + ) +}
\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/events/ObservabilityEvent.kt b/wallet/src/main/java/net/taler/wallet/events/ObservabilityEvent.kt new file mode 100644 index 0000000..a50cde2 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/events/ObservabilityEvent.kt @@ -0,0 +1,66 @@ +/* + * 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.events + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.time.LocalDateTime + + +@Serializable(with = ObservabilityEventSerializer::class) +class ObservabilityEvent( + val body: JsonObject, + val timestamp: LocalDateTime, + val type: String, +) + +class ObservabilityEventSerializer: KSerializer<ObservabilityEvent> { + private val jsonElementSerializer = JsonElement.serializer() + + override val descriptor: SerialDescriptor + get() = jsonElementSerializer.descriptor + + override fun deserialize(decoder: Decoder): ObservabilityEvent { + require(decoder is JsonDecoder) + val jsonObject = decoder + .decodeJsonElement() + .jsonObject + + val type = jsonObject["type"] + ?.jsonPrimitive + ?.content + ?: "unknown" + + return ObservabilityEvent( + body = jsonObject, + timestamp = LocalDateTime.now(), + type = type, + ) + } + + override fun serialize(encoder: Encoder, value: ObservabilityEvent) { + encoder.encodeSerializableValue(JsonObject.serializer(), value.body) + } +}
\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt index cb294ac..674632e 100644 --- a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt +++ b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt @@ -33,6 +33,7 @@ interface ExchangeClickListener { fun onExchangeSelected(item: ExchangeItem) fun onManualWithdraw(item: ExchangeItem) fun onPeerReceive(item: ExchangeItem) + fun onExchangeReload(item: ExchangeItem) fun onExchangeDelete(item: ExchangeItem) } @@ -99,6 +100,10 @@ internal class ExchangeAdapter( listener.onPeerReceive(item) true } + R.id.action_reload -> { + listener.onExchangeReload(item) + true + } R.id.action_delete -> { listener.onExchangeDelete(item) true diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt index 5482b5a..8a40bff 100644 --- a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt @@ -110,6 +110,13 @@ open class ExchangeListFragment : Fragment(), ExchangeClickListener { showError(error.userFacingMsg) } }) + exchangeManager.reloadError.observe(viewLifecycleOwner, EventObserver { error -> + if (model.devMode.value == true) { + showError(error) + } else { + showError(error.userFacingMsg) + } + }) } protected open fun onExchangeUpdate(exchanges: List<ExchangeItem>) { @@ -145,6 +152,10 @@ open class ExchangeListFragment : Fragment(), ExchangeClickListener { findNavController().navigate(R.id.action_global_receiveFunds) } + override fun onExchangeReload(item: ExchangeItem) { + exchangeManager.reload(item.exchangeBaseUrl) + } + override fun onExchangeDelete(item: ExchangeItem) { val optionsArray = arrayOf(getString(R.string.exchange_delete_force)) val checkedArray = BooleanArray(1) { false } diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt index eb01cab..fa357b5 100644 --- a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt +++ b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt @@ -62,6 +62,9 @@ class ExchangeManager( private val mDeleteError = MutableLiveData<Event<TalerErrorInfo>>() val deleteError: LiveData<Event<TalerErrorInfo>> = mDeleteError + private val mReloadError = MutableLiveData<Event<TalerErrorInfo>>() + val reloadError: LiveData<Event<TalerErrorInfo>> = mReloadError + var withdrawalExchange: ExchangeItem? = null private fun list(): LiveData<List<ExchangeItem>> { @@ -95,6 +98,22 @@ class ExchangeManager( } } + fun reload(exchangeUrl: String, force: Boolean = true) = scope.launch { + mProgress.value = true + api.request<Unit>("updateExchangeEntry") { + put("exchangeBaseUrl", exchangeUrl) + put("force", force) + }.onError { + Log.e(TAG, "Error reloading exchange: $it") + mProgress.value = false + mReloadError.value = it.toEvent() + }.onSuccess { + mProgress.value = false + Log.d(TAG, "Exchange $exchangeUrl reloaded") + list() + } + } + fun delete(exchangeUrl: String, purge: Boolean = false) = scope.launch { mProgress.value = true api.request<Unit>("deleteExchange") { diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt b/wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt index ce0bd82..0015e1c 100644 --- a/wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt +++ b/wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt @@ -21,6 +21,12 @@ import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.cleanExchange @Serializable +data class BuiltinExchange( + val exchangeBaseUrl: String, + val currencyHint: String? = null, +) + +@Serializable data class ExchangeItem( val exchangeBaseUrl: String, // can be null before exchange info in wallet-core was fully loaded 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..ffa4875 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,7 @@ 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.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -26,10 +26,12 @@ import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp 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 { @@ -86,7 +88,7 @@ fun PayTemplateComposable( @Composable fun PayTemplateError(message: String) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.padding(16.dp).fillMaxSize(), contentAlignment = Center, ) { Text( @@ -99,12 +101,7 @@ fun PayTemplateError(message: String) { @Composable fun PayTemplateLoading() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Center, - ) { - CircularProgressIndicator() - } + LoadingScreen() } @Preview @@ -149,7 +146,7 @@ fun PayTemplateInsufficientBalancePreview() { } } -@Preview +@Preview(widthDp = 300) @Composable fun PayTemplateAlreadyPaidPreview() { TalerSurface { diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt index 79ab542..8f2fb96 100644 --- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt @@ -31,8 +31,6 @@ import kotlinx.coroutines.launch import net.taler.common.Amount import net.taler.wallet.MainViewModel import net.taler.wallet.R -import net.taler.wallet.backend.BackendManager -import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.exchanges.ExchangeItem @@ -52,9 +50,7 @@ class OutgoingPullFragment : Fragment() { val amount = arguments?.getString("amount")?.let { Amount.fromJSONString(it) } ?: error("no amount passed") - val scopeInfo: ScopeInfo? = arguments?.getString("scopeInfo")?.let { - BackendManager.json.decodeFromString(it) - } + val scopeInfo = transactionManager.selectedScope val spec = scopeInfo?.let { balanceManager.getSpecForScopeInfo(it) } return ComposeView(requireContext()).apply { diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt index fa3c79a..01fb566 100644 --- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt @@ -33,8 +33,6 @@ import kotlinx.coroutines.launch import net.taler.common.Amount import net.taler.wallet.MainViewModel import net.taler.wallet.R -import net.taler.wallet.backend.BackendManager -import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.showError @@ -60,9 +58,7 @@ class OutgoingPushFragment : Fragment() { val amount = arguments?.getString("amount")?.let { Amount.fromJSONString(it) } ?: error("no amount passed") - val scopeInfo: ScopeInfo? = arguments?.getString("scopeInfo")?.let { - BackendManager.json.decodeFromString(it) - } + val scopeInfo = transactionManager.selectedScope val spec = scopeInfo?.let { balanceManager.getSpecForScopeInfo(it) } requireActivity().onBackPressedDispatcher.addCallback( diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt index 0435665..38eeb9b 100644 --- a/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt @@ -33,6 +33,7 @@ import net.taler.wallet.BuildConfig.VERSION_CODE import net.taler.wallet.BuildConfig.VERSION_NAME import net.taler.wallet.MainViewModel import net.taler.wallet.R +import net.taler.wallet.showError import net.taler.wallet.withdraw.WithdrawTestStatus import java.lang.System.currentTimeMillis @@ -108,7 +109,9 @@ class SettingsFragment : PreferenceFragmentCompat() { devPrefs.forEach { it.isVisible = enabled } } prefDevMode.setOnPreferenceChangeListener { _, newValue -> - model.devMode.value = newValue as Boolean + model.setDevMode(newValue as Boolean) { error -> + showError(error) + } true } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt index 22dcc3f..3b686a6 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt @@ -36,6 +36,7 @@ import net.taler.common.CurrencySpecification import net.taler.common.exhaustive import net.taler.common.toRelativeTime import net.taler.wallet.R +import net.taler.wallet.getThemeColor import net.taler.wallet.transactions.TransactionAdapter.TransactionViewHolder import net.taler.wallet.transactions.TransactionMajorState.Aborted import net.taler.wallet.transactions.TransactionMajorState.Failed @@ -97,7 +98,7 @@ internal class TransactionAdapter( private val amountColor = amount.currentTextColor private val extraInfoColor = extraInfoView.currentTextColor - private val red = getColor(context, R.color.red) + private val red = context.getThemeColor(R.attr.colorError) private val green = getColor(context, R.color.green) fun bind(transaction: Transaction, selected: Boolean) { diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt new file mode 100644 index 0000000..9138345 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt @@ -0,0 +1,163 @@ +/* + * 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.transactions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.taler.common.Amount +import net.taler.common.CurrencySpecification +import net.taler.common.Timestamp +import net.taler.common.toAbsoluteTime +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorCode +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.transactions.LossEventType.DenomExpired +import net.taler.wallet.transactions.LossEventType.DenomUnoffered +import net.taler.wallet.transactions.LossEventType.DenomVanished +import net.taler.wallet.transactions.TransactionAction.Abort +import net.taler.wallet.transactions.TransactionAction.Retry +import net.taler.wallet.transactions.TransactionAction.Suspend +import net.taler.wallet.transactions.TransactionMajorState.Pending + +class TransactionLossFragment: TransactionDetailFragment() { + val scope get() = transactionManager.selectedScope + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = ComposeView(requireContext()).apply { + setContent { + val t = transactionManager.selectedTransaction.observeAsState().value + val spec = scope?.let { balanceManager.getSpecForScopeInfo(it) } + + TalerSurface { + if (t is TransactionDenomLoss) { + TransitionLossComposable(t, devMode, spec) { + onTransitionButtonClicked(t, it) + } + } + } + } + } +} + +@Composable +fun TransitionLossComposable( + t: TransactionDenomLoss, + devMode: Boolean, + spec: CurrencySpecification?, + onTransition: (t: TransactionAction) -> Unit, +) { + val scrollState = rememberScrollState() + val context = LocalContext.current + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.padding(16.dp), + text = t.timestamp.ms.toAbsoluteTime(context).toString(), + style = MaterialTheme.typography.bodyLarge, + ) + + TransactionAmountComposable( + label = stringResource(id = R.string.loss_amount), + amount = t.amountEffective.withSpec(spec), + amountType = AmountType.Negative, + ) + + TransactionInfoComposable( + label = stringResource(id = R.string.loss_reason), + info = stringResource( + when(t.lossEventType) { + DenomExpired -> R.string.loss_reason_expired + DenomVanished -> R.string.loss_reason_vanished + DenomUnoffered -> R.string.loss_reason_unoffered + } + ) + ) + + TransitionsComposable(t, devMode, onTransition) + + if (devMode && t.error != null) { + ErrorTransactionButton(error = t.error) + } + } +} + +fun previewLossTransaction(lossEventType: LossEventType) = + TransactionDenomLoss( + transactionId = "transactionId", + timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), + txState = TransactionState(Pending), + txActions = listOf(Retry, Suspend, Abort), + amountRaw = Amount.fromString("TESTKUDOS", "0.3"), + amountEffective = Amount.fromString("TESTKUDOS", "0.3"), + error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED), + lossEventType = lossEventType, + ) + +@Composable +@Preview +fun TransitionLossComposableExpiredPreview() { + val t = previewLossTransaction(DenomExpired) + Surface { + TransitionLossComposable(t, true, null) {} + } +} + +@Composable +@Preview +fun TransitionLossComposableVanishedPreview() { + val t = previewLossTransaction(DenomVanished) + Surface { + TransitionLossComposable(t, true, null) {} + } +} + +@Composable +@Preview +fun TransactionLossComposableUnofferedPreview() { + val t = previewLossTransaction(DenomUnoffered) + Surface { + TransitionLossComposable(t, true, null) {} + } +} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt index 5399287..d0dec41 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt @@ -82,6 +82,8 @@ class TransactionManager( mProgress.postValue(false) }.onSuccess { result -> val transactions = LinkedList(result.transactions) + val comparator = compareBy<Transaction> { it.txState.major == Pending } + transactions.sortWith(comparator) transactions.reverse() // show latest first mProgress.value = false diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionState.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionState.kt index 7091c90..f89be83 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionState.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionState.kt @@ -79,102 +79,21 @@ enum class TransactionMajorState { @Serializable enum class TransactionMinorState { - @SerialName("unknown") - Unknown, - - @SerialName("deposit") - Deposit, - @SerialName("kyc") KycRequired, - @SerialName("aml") - AmlRequired, - - @SerialName("merge-kyc") - MergeKycRequired, - - @SerialName("track") - Track, - - @SerialName("submit-payment") - SubmitPayment, - - @SerialName("rebind-session") - RebindSession, - - @SerialName("refresh") - Refresh, - - @SerialName("pickup") - Pickup, - - @SerialName("auto-refund") - AutoRefund, - - @SerialName("user") - User, - - @SerialName("bank") - Bank, - @SerialName("exchange") Exchange, - @SerialName("claim-proposal") - ClaimProposal, - - @SerialName("check-refund") - CheckRefund, - @SerialName("create-purse") CreatePurse, - @SerialName("delete-purse") - DeletePurse, - - @SerialName("refresh-expired") - RefreshExpired, - @SerialName("ready") Ready, - @SerialName("merge") - Merge, - - @SerialName("repurchase") - Repurchase, - - @SerialName("bank-register-reserve") - BankRegisterReserve, - @SerialName("bank-confirm-transfer") BankConfirmTransfer, - @SerialName("withdraw-coins") - WithdrawCoins, - @SerialName("exchange-wait-reserve") ExchangeWaitReserve, - - @SerialName("aborting-bank") - AbortingBank, - - @SerialName("refused") - Refused, - - @SerialName("withdraw") - Withdraw, - - @SerialName("merchant-order-proposed") - MerchantOrderProposed, - - @SerialName("proposed") - Proposed, - - @SerialName("refund-available") - RefundAvailable, - - @SerialName("accept-refund") - AcceptRefund } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt b/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt index be36a13..7ccdbde 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt @@ -216,6 +216,16 @@ data class WithdrawalExchangeAccountDetails ( val paytoUri: String, /** + * Status that indicates whether the account can be used + * by the user to send funds for a withdrawal. + * + * ok: account should be shown to the user + * error: account should not be shown to the user, UIs might render the error (in conversionError), + * especially in dev mode. + */ + val status: Status, + + /** * Transfer amount. Might be in a different currency than the requested * amount for withdrawal. * @@ -235,7 +245,23 @@ data class WithdrawalExchangeAccountDetails ( * exchange. */ val creditRestrictions: List<AccountRestriction>? = null, -) + + /** + * Label given to the account or the account's bank by the exchange. + */ + val bankLabel: String? = null, + + val priority: Int? = null, +) { + @Serializable + enum class Status { + @SerialName("ok") + Ok, + + @SerialName("error") + Error; + } +} @Serializable sealed class AccountRestriction { @@ -506,6 +532,47 @@ class TransactionPeerPushCredit( } /** + * A transaction to indicate financial loss due to denominations + * that became unusable for deposits. + */ +@Serializable +@SerialName("denom-loss") +class TransactionDenomLoss( + override val transactionId: String, + override val timestamp: Timestamp, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, + override val error: TalerErrorInfo? = null, + override val amountRaw: Amount, + override val amountEffective: Amount, + val lossEventType: LossEventType, +): Transaction() { + override val icon: Int = R.drawable.transaction_loss + override val detailPageNav = R.id.nav_transactions_detail_loss + + @Transient + override val amountType: AmountType = AmountType.Negative + + override fun getTitle(context: Context): String { + return context.getString(R.string.transaction_denom_loss) + } + + override val generalTitleRes: Int = R.string.transaction_denom_loss +} + +@Serializable +enum class LossEventType { + @SerialName("denom-expired") + DenomExpired, + + @SerialName("denom-vanished") + DenomVanished, + + @SerialName("denom-unoffered") + DenomUnoffered +} + +/** * This represents a transaction that we can not parse for some reason. */ class DummyTransaction( diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt b/wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt index 5155b5b..20f8280 100644 --- a/wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt +++ b/wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt @@ -129,6 +129,7 @@ fun TransactionWithdrawalComposablePreview() { WithdrawalExchangeAccountDetails( paytoUri = "payto://IBAN/1231231231", transferAmount = Amount.fromJSONString("NETZBON:42.23"), + status = WithdrawalExchangeAccountDetails.Status.Ok, currencySpecification = CurrencySpecification( name = "NETZBON", numFractionalInputDigits = 2, diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenTransfer.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenTransfer.kt index 35ff89c..75d03b5 100644 --- a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenTransfer.kt +++ b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenTransfer.kt @@ -53,6 +53,7 @@ import net.taler.wallet.compose.copyToClipBoard import net.taler.wallet.transactions.AmountType import net.taler.wallet.transactions.TransactionAmountComposable import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails +import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails.Status.* import net.taler.wallet.withdraw.TransferData import net.taler.wallet.withdraw.WithdrawStatus @@ -66,13 +67,20 @@ fun ScreenTransfer( // TODO: show some placeholder if (status.withdrawalTransfers.isEmpty()) return - val defaultTransfer = status.withdrawalTransfers[0] + val transfers = status.withdrawalTransfers.filter { + // TODO: in dev mode, show debug info when status is `Error' + it.withdrawalAccount.status == Ok + }.sortedByDescending { + it.withdrawalAccount.priority + } + + val defaultTransfer = transfers[0] var selectedTransfer by remember { mutableStateOf(defaultTransfer) } Column { if (status.withdrawalTransfers.size > 1) { TransferAccountChooser( - accounts = status.withdrawalTransfers.map { it.withdrawalAccount }, + accounts = transfers.map { it.withdrawalAccount }, selectedAccount = selectedTransfer.withdrawalAccount, onSelectAccount = { account -> status.withdrawalTransfers.find { @@ -92,8 +100,8 @@ fun ScreenTransfer( is TransferData.Taler -> TransferTaler( transfer = transfer, exchangeBaseUrl = status.exchangeBaseUrl, - transactionAmountRaw = status.transactionAmountRaw, - transactionAmountEffective = status.transactionAmountEffective, + transactionAmountRaw = status.transactionAmountRaw.withSpec(spec), + transactionAmountEffective = status.transactionAmountEffective.withSpec(spec), ) is TransferData.IBAN -> TransferIBAN( @@ -236,7 +244,9 @@ fun TransferAccountChooser( selected = selectedAccount.paytoUri == account.paytoUri, onClick = { onSelectAccount(account) }, text = { - if (account.currencySpecification?.name != null) { + if (!account.bankLabel.isNullOrEmpty()) { + Text(account.bankLabel) + } else if (account.currencySpecification?.name != null) { Text(stringResource( R.string.withdraw_account_currency, index + 1, @@ -274,6 +284,7 @@ fun ScreenTransferPreview() { withdrawalAccount = WithdrawalExchangeAccountDetails( paytoUri = "https://taler.net/kudos", transferAmount = Amount("KUDOS", 10, 0), + status = Ok, currencySpecification = CurrencySpecification( "KUDOS", numFractionalInputDigits = 2, @@ -295,6 +306,7 @@ fun ScreenTransferPreview() { withdrawalAccount = WithdrawalExchangeAccountDetails( paytoUri = "https://taler.net/btc", transferAmount = Amount("BTC", 0, 14000000), + status = Ok, currencySpecification = CurrencySpecification( "Bitcoin", numFractionalInputDigits = 2, diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferIBAN.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferIBAN.kt index 6c1b014..d0bc893 100644 --- a/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferIBAN.kt +++ b/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferIBAN.kt @@ -42,6 +42,12 @@ fun TransferIBAN( transactionAmountRaw: Amount, transactionAmountEffective: Amount, ) { + val transferAmount = transfer + .withdrawalAccount + .transferAmount + ?.withSpec(transfer.withdrawalAccount.currencySpecification) + ?: transfer.amountRaw + Column( modifier = Modifier.padding(all = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -49,7 +55,7 @@ fun TransferIBAN( Text( text = stringResource( R.string.withdraw_manual_ready_intro, - transfer.amountRaw.toString()), + transferAmount), style = MaterialTheme.typography.bodyLarge, modifier = Modifier .padding(vertical = 8.dp) @@ -78,14 +84,10 @@ fun TransferIBAN( info = cleanExchange(exchangeBaseUrl), ) - transfer.withdrawalAccount.transferAmount?.let { amount -> - WithdrawalAmountTransfer( - amountRaw = transactionAmountRaw, - amountEffective = transactionAmountEffective, - conversionAmountRaw = amount.withSpec( - transfer.withdrawalAccount.currencySpecification, - ), - ) - } + WithdrawalAmountTransfer( + amountRaw = transactionAmountRaw, + amountEffective = transactionAmountEffective, + conversionAmountRaw = transferAmount, + ) } }
\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferTaler.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferTaler.kt index cc6597e..2ec43b9 100644 --- a/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferTaler.kt +++ b/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferTaler.kt @@ -42,6 +42,12 @@ fun TransferTaler( transactionAmountRaw: Amount, transactionAmountEffective: Amount, ) { + val transferAmount = transfer + .withdrawalAccount + .transferAmount + ?.withSpec(transfer.withdrawalAccount.currencySpecification) + ?: transfer.amountRaw + Column( modifier = Modifier.padding(all = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -49,7 +55,7 @@ fun TransferTaler( Text( text = stringResource( R.string.withdraw_manual_ready_intro, - transfer.amountRaw.toString()), + transferAmount), style = MaterialTheme.typography.bodyLarge, modifier = Modifier .padding(vertical = 8.dp) @@ -78,12 +84,10 @@ fun TransferTaler( info = cleanExchange(exchangeBaseUrl), ) - transfer.withdrawalAccount.transferAmount?.let { amount -> - WithdrawalAmountTransfer( - amountRaw = transactionAmountRaw, - amountEffective = transactionAmountEffective, - conversionAmountRaw = amount, - ) - } + WithdrawalAmountTransfer( + amountRaw = transactionAmountRaw, + amountEffective = transactionAmountEffective, + conversionAmountRaw = transferAmount, + ) } }
\ No newline at end of file diff --git a/wallet/src/main/res/drawable/transaction_loss.xml b/wallet/src/main/res/drawable/transaction_loss.xml new file mode 100644 index 0000000..ffc9a2e --- /dev/null +++ b/wallet/src/main/res/drawable/transaction_loss.xml @@ -0,0 +1,26 @@ +<!-- + ~ 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/> + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:tint="?attr/colorControlNormal" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M17.12,9.88C16.56,9.32 15.8,9 15,9S13.44,9.32 12.88,9.88C12.32,10.44 12,11.2 12,12S12.32,13.56 12.88,14.12 14.2,15 15,15 16.56,14.68 17.12,14.12 18,12.8 18,12 17.68,10.44 17.12,9.88M7,6V18H23V6H7M21,14C20.47,14 19.96,14.21 19.59,14.59C19.21,14.96 19,15.47 19,16H11C11,15.47 10.79,14.96 10.41,14.59C10.04,14.21 9.53,14 9,14V10C9.53,10 10.04,9.79 10.41,9.41C10.79,9.04 11,8.53 11,8H19C19,8.53 19.21,9.04 19.59,9.41C19.96,9.79 20.47,10 21,10V14M5,8H3C2.45,8 2,7.55 2,7C2,6.45 2.45,6 3,6H5V8M5,13H2C1.45,13 1,12.55 1,12C1,11.45 1.45,11 2,11H5V13M5,18H1C0.448,18 0,17.55 0,17C0,16.45 0.448,16 1,16H5V18Z"/> +</vector> diff --git a/wallet/src/main/res/layout/list_item_transaction.xml b/wallet/src/main/res/layout/list_item_transaction.xml index 64d9045..ad792ae 100644 --- a/wallet/src/main/res/layout/list_item_transaction.xml +++ b/wallet/src/main/res/layout/list_item_transaction.xml @@ -22,9 +22,9 @@ android:layout_height="wrap_content" android:background="?attr/selectableItemBackground" android:paddingStart="16dp" - android:paddingTop="8dp" + android:paddingTop="12dp" android:paddingEnd="16dp" - android:paddingBottom="8dp"> + android:paddingBottom="12dp"> <ImageView android:id="@+id/icon" @@ -50,11 +50,11 @@ <TextView android:id="@+id/extraInfoView" + style="@style/TransactionSubtitle" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:layout_marginEnd="8dp" - android:textSize="14sp" android:visibility="gone" app:layout_constraintEnd_toStartOf="@+id/barrier" app:layout_constraintStart_toStartOf="@+id/title" @@ -64,11 +64,11 @@ <TextView android:id="@+id/time" + style="@style/TransactionTimestamp" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:layout_marginEnd="8dp" - android:textSize="14sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@+id/barrier" app:layout_constraintStart_toStartOf="@+id/title" @@ -84,9 +84,9 @@ <TextView android:id="@+id/amount" + style="@style/TransactionAmount" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:textSize="24sp" app:layout_constraintBottom_toTopOf="@+id/pendingView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" diff --git a/wallet/src/main/res/menu/exchange.xml b/wallet/src/main/res/menu/exchange.xml index 1d2c2e5..d99ff00 100644 --- a/wallet/src/main/res/menu/exchange.xml +++ b/wallet/src/main/res/menu/exchange.xml @@ -22,6 +22,9 @@ android:id="@+id/action_receive_peer" android:title="@string/receive_peer" /> <item + android:id="@+id/action_reload" + android:title="@string/exchange_reload" /> + <item android:id="@+id/action_delete" android:title="@string/transactions_delete" /> </menu> diff --git a/wallet/src/main/res/menu/global_dev.xml b/wallet/src/main/res/menu/global_dev.xml new file mode 100644 index 0000000..d6f73b9 --- /dev/null +++ b/wallet/src/main/res/menu/global_dev.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ 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/> + --> + +<menu xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> + <item + android:id="@+id/action_show_logs" + android:title="@string/show_logs" + android:icon="@drawable/ic_bug_report" + app:showAsAction="ifRoom" /> +</menu>
\ No newline at end of file diff --git a/wallet/src/main/res/navigation/nav_graph.xml b/wallet/src/main/res/navigation/nav_graph.xml index c48d93d..f6c90ab 100644 --- a/wallet/src/main/res/navigation/nav_graph.xml +++ b/wallet/src/main/res/navigation/nav_graph.xml @@ -34,6 +34,65 @@ </fragment> <fragment + android:id="@+id/handleUri" + android:name="net.taler.wallet.HandleUriFragment" + android:label="@string/handle_uri_title"> + <argument + android:name="uri" + app:argType="string" + app:nullable="false" /> + <argument + android:name="from" + app:argType="string" + app:nullable="false" /> + + <action + android:id="@+id/action_handleUri_to_receiveFunds" + app:destination="@id/receiveFunds" + app:popUpTo="@id/nav_main" /> + + <action + android:id="@+id/action_handleUri_to_sendFunds" + app:destination="@id/sendFunds" + app:popUpTo="@id/nav_main" /> + + <action + android:id="@+id/action_handleUri_to_promptWithdraw" + app:destination="@id/promptWithdraw" + app:popUpTo="@id/nav_main" /> + + <action + android:id="@+id/action_handleUri_to_manualWithdrawal" + app:destination="@id/nav_exchange_manual_withdrawal" + app:popUpTo="@id/nav_main" /> + + <action + android:id="@+id/action_handleUri_to_promptPayment" + app:destination="@id/promptPayment" + app:popUpTo="@id/nav_main" /> + + <action + android:id="@+id/action_handleUri_to_promptPullPayment" + app:destination="@id/promptPullPayment" + app:popUpTo="@id/nav_main" /> + + <action + android:id="@+id/action_handleUri_to_promptPushPayment" + app:destination="@id/promptPushPayment" + app:popUpTo="@id/nav_main" /> + + <action + android:id="@+id/action_handleUri_to_promptPayTemplate" + app:destination="@id/promptPayTemplate" + app:popUpTo="@id/nav_main" /> + + <action + android:id="@+id/action_handleUri_to_nav_payto_uri" + app:destination="@id/nav_payto_uri" + app:popUpTo="@id/nav_main" /> + </fragment> + + <fragment android:id="@+id/receiveFunds" android:name="net.taler.wallet.ReceiveFundsFragment" android:label="@string/transactions_receive_funds"> @@ -134,10 +193,6 @@ app:argType="string" app:nullable="false" /> <argument - android:name="scopeInfo" - app:argType="string" - app:nullable="true" /> - <argument android:name="IBAN" android:defaultValue="@null" app:argType="string" @@ -162,11 +217,6 @@ android:defaultValue="@null" app:argType="string" app:nullable="true" /> - <argument - android:name="scopeInfo" - android:defaultValue="@null" - app:argType="string" - app:nullable="true" /> <action android:id="@+id/action_nav_peer_pull_to_nav_main" app:destination="@id/nav_main" @@ -186,11 +236,6 @@ android:defaultValue="@null" app:argType="string" app:nullable="true" /> - <argument - android:name="scopeInfo" - android:defaultValue="@null" - app:argType="string" - app:nullable="true" /> <action android:id="@+id/action_nav_peer_push_to_nav_main" app:destination="@id/nav_main" @@ -279,6 +324,11 @@ android:label="@string/transactions_detail_title" /> <fragment + android:id="@+id/nav_transactions_detail_loss" + android:name="net.taler.wallet.transactions.TransactionLossFragment" + android:label="@string/transactions_detail_title" /> + + <fragment android:id="@+id/nav_transactions_detail_dummy" android:name="net.taler.wallet.transactions.TransactionDummyFragment" android:label="@string/transactions_detail_title" /> @@ -333,6 +383,10 @@ tools:layout="@layout/fragment_error" /> <action + android:id="@+id/action_global_handle_uri" + app:destination="@id/handleUri" /> + + <action android:id="@+id/action_global_receiveFunds" app:destination="@id/receiveFunds" /> @@ -341,30 +395,10 @@ app:destination="@id/sendFunds" /> <action - android:id="@+id/action_global_promptWithdraw" - app:destination="@id/promptWithdraw" /> - - <action - android:id="@+id/action_global_manual_withdrawal" - app:destination="@id/nav_exchange_manual_withdrawal" /> - - <action android:id="@+id/action_global_promptPayment" app:destination="@id/promptPayment" /> <action - android:id="@+id/action_global_prompt_pull_payment" - app:destination="@id/promptPullPayment" /> - - <action - android:id="@+id/action_global_prompt_push_payment" - app:destination="@id/promptPushPayment" /> - - <action - android:id="@+id/action_global_prompt_pay_template" - app:destination="@id/promptPayTemplate" /> - - <action android:id="@+id/action_nav_transactions_detail_withdrawal" app:destination="@id/nav_transactions_detail_withdrawal" /> diff --git a/wallet/src/main/res/values-de/strings.xml b/wallet/src/main/res/values-de/strings.xml index 5ea98e9..f4e3fed 100644 --- a/wallet/src/main/res/values-de/strings.xml +++ b/wallet/src/main/res/values-de/strings.xml @@ -120,7 +120,7 @@ <string name="paste_invalid">Die Zwischenablage enthält einen ungültigen Datentyp</string> <string name="uri_invalid">Keine gültige Taler-URI</string> <string name="ok">Bestätigen</string> - <string name="cancel">Abbrechen</string> + <string name="cancel">Zurück</string> <string name="search">Suche</string> <string name="menu">Menü</string> <string name="nav_error">Fehler</string> @@ -161,7 +161,7 @@ <string name="transactions_delete_dialog_message">Sind Sie sicher, dass Sie diese Transaktion aus Ihrem Wallet entfernen möchten?</string> <string name="transactions_delete_dialog_title">Transaktion löschen</string> <string name="receive_peer_payment_intro">Möchten Sie diese Zahlung erhalten?</string> - <string name="transactions_abort">Abbrechen</string> + <string name="transactions_abort">Abbruch ausführen</string> <string name="payment_pay_template_title">Passen Sie Ihre Bestellung an</string> <string name="send_intro">Wählen Sie aus, wohin Sie Geld senden möchten:</string> <string name="send_deposit_title">Einzahlung auf ein Bankkonto</string> diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml index 2ec3d40..8466e2d 100644 --- a/wallet/src/main/res/values/strings.xml +++ b/wallet/src/main/res/values/strings.xml @@ -73,6 +73,8 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="host_apdu_service_desc">Taler NFC Payments</string> + <string name="handle_uri_title">Loading action</string> + <string name="balances_title">Balances</string> <string name="amount_positive">+%s</string> <string name="amount_negative">-%s</string> @@ -126,6 +128,7 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="transaction_peer_pull_debit">Invoice paid</string> <string name="transaction_peer_push_credit">Push payment</string> <string name="transaction_action_kyc">Complete KYC</string> + <string name="transaction_denom_loss">Loss of funds</string> <string name="transaction_dummy_title">Unknown Transaction</string> <string name="payment_title">Payment</string> @@ -241,6 +244,7 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="exchange_delete">Delete exchange</string> <string name="exchange_delete_force">Force deletion (purge)</string> <string name="exchange_dialog_delete_message">Are you sure you want to delete this exchange? Forcing this operation will result in a loss of funds.</string> + <string name="exchange_reload">Reload information</string> <string name="exchange_not_contacted">Exchange not contacted</string> <string name="exchange_add_url">Enter address of exchange</string> <string name="exchange_add_error">Could not add exchange</string> @@ -267,10 +271,23 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="exchange_tos_accept">Accept Terms of Service</string> <string name="exchange_tos_error">Error showing Terms of Service: %s</string> + <string name="loss_amount">Amount lost</string> + <string name="loss_reason">Reason</string> + <string name="loss_reason_expired">Funds were not renewed, because the wallet was not opened for a long time</string> + <string name="loss_reason_vanished">The payment provider lost the record of the funds</string> + <string name="loss_reason_unoffered">The payment provider stopped offering the denomination backing the funds</string> + + <string name="pending_operations_title">Pending Operations</string> <string name="pending_operations_refuse">Refuse Proposal</string> <string name="pending_operations_no_action">(no action)</string> + <!-- Observability --> + <string name="show_logs">Show logs</string> + <string name="observability_title">Internal event log</string> + <string name="observability_show_json">Show JSON</string> + <string name="observability_hide_json">Hide JSON</string> + <string name="settings_dev_mode">Developer Mode</string> <string name="settings_dev_mode_summary">Shows more information intended for debugging</string> <string name="settings_withdraw_testkudos">Withdraw TESTKUDOS</string> diff --git a/wallet/src/main/res/values/styles.xml b/wallet/src/main/res/values/styles.xml index d7d939f..961c8da 100644 --- a/wallet/src/main/res/values/styles.xml +++ b/wallet/src/main/res/values/styles.xml @@ -98,7 +98,19 @@ <style name="DialogTheme" parent="Theme.Material3.DayNight.Dialog.Alert" /> <style name="TransactionTitle"> - <item name="android:textSize">16sp</item> + <item name="android:textAppearance">@style/TextAppearance.Material3.TitleMedium</item> + </style> + + <style name="TransactionSubtitle"> + <item name="android:textAppearance">@style/TextAppearance.Material3.BodyMedium</item> + </style> + + <style name="TransactionTimestamp"> + <item name="android:textAppearance">@style/TextAppearance.Material3.LabelMedium</item> + </style> + + <style name="TransactionAmount"> + <item name="android:textAppearance">@style/TextAppearance.Material3.TitleLarge</item> </style> <style name="TransactionLabel"> |