summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cashier/src/main/res/values-ru/strings.xml43
-rwxr-xr-ximport-svg-assets.sh51
-rw-r--r--taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt2
-rw-r--r--wallet/build.gradle6
-rw-r--r--wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt272
-rw-r--r--wallet/src/main/java/net/taler/wallet/MainActivity.kt225
-rw-r--r--wallet/src/main/java/net/taler/wallet/MainViewModel.kt71
-rw-r--r--wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt9
-rw-r--r--wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt12
-rw-r--r--wallet/src/main/java/net/taler/wallet/Utils.kt6
-rw-r--r--wallet/src/main/java/net/taler/wallet/backend/ApiResponse.kt2
-rw-r--r--wallet/src/main/java/net/taler/wallet/backend/BackendManager.kt3
-rw-r--r--wallet/src/main/java/net/taler/wallet/backend/InitResponse.kt43
-rw-r--r--wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt10
-rw-r--r--wallet/src/main/java/net/taler/wallet/compose/LoadingScreen.kt34
-rw-r--r--wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt7
-rw-r--r--wallet/src/main/java/net/taler/wallet/events/ObservabilityDialog.kt163
-rw-r--r--wallet/src/main/java/net/taler/wallet/events/ObservabilityEvent.kt66
-rw-r--r--wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt5
-rw-r--r--wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt11
-rw-r--r--wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt19
-rw-r--r--wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt6
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt15
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt6
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt6
-rw-r--r--wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt5
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt3
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt163
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt2
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionState.kt81
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt69
-rw-r--r--wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt1
-rw-r--r--wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenTransfer.kt22
-rw-r--r--wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferIBAN.kt22
-rw-r--r--wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferTaler.kt20
-rw-r--r--wallet/src/main/res/drawable/transaction_loss.xml26
-rw-r--r--wallet/src/main/res/layout/list_item_transaction.xml10
-rw-r--r--wallet/src/main/res/menu/exchange.xml3
-rw-r--r--wallet/src/main/res/menu/global_dev.xml24
-rw-r--r--wallet/src/main/res/navigation/nav_graph.xml102
-rw-r--r--wallet/src/main/res/values-de/strings.xml4
-rw-r--r--wallet/src/main/res/values/strings.xml17
-rw-r--r--wallet/src/main/res/values/styles.xml14
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">Для тестирования, вы можете &lt;a href=%s&gt;создать тестовую учётную запист в демонстрационном банке&lt;/a&gt;.</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">