gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-taler-android] branch master updated (78096ab -> 3ab6f15)


From: gnunet
Subject: [taler-taler-android] branch master updated (78096ab -> 3ab6f15)
Date: Tue, 11 Aug 2020 22:35:44 +0200

This is an automated email from the git hooks/post-receive script.

torsten-grote pushed a change to branch master
in repository taler-android.

    from 78096ab  [wallet] support Timestamp with "never"
     new a20adab  [wallet] show error icon for transactions with error
     new f0670e2  [pos] Improve coroutine-based merchant library access
     new d13be7c  [wallet] start to move deserialization into the backend API
     new 3ab6f15  [wallet] upgrade wallet-core and adapt payment API

The 4 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 merchant-lib/build.gradle                          |   1 +
 .../main/java/net/taler/merchantlib/MerchantApi.kt |  76 ++++++++-----
 .../main/java/net/taler/merchantlib/Response.kt    |   4 +-
 .../java/net/taler/merchantlib/MerchantApiTest.kt  |  20 ++--
 .../net/taler/merchantpos/config/ConfigManager.kt  |   6 +-
 .../taler/merchantpos/history/HistoryManager.kt    |  19 ++--
 .../taler/merchantpos/payment/PaymentManager.kt    |  72 ++++++------
 .../net/taler/merchantpos/refund/RefundManager.kt  |  24 ++--
 taler-kotlin-android/build.gradle                  |   7 +-
 wallet/build.gradle                                |   7 +-
 .../main/java/net/taler/wallet/MainViewModel.kt    |  63 ++++++-----
 .../net/taler/wallet/backend/WalletBackendApi.kt   |  96 ++++++++++++----
 .../net/taler/wallet/backend/WalletResponse.kt     | 117 ++++++++++++++++++++
 .../net/taler/wallet/balances/BalanceAdapter.kt    |   2 +
 .../net/taler/wallet/balances/BalanceResponse.kt   |  11 +-
 .../net/taler/wallet/exchanges/ExchangeAdapter.kt  |   2 +
 .../net/taler/wallet/payment/PaymentManager.kt     |  72 ++++++------
 .../net/taler/wallet/payment/PaymentResponses.kt   |  17 +++
 .../taler/wallet/payment/PromptPaymentFragment.kt  |   7 +-
 .../wallet/transactions/TransactionAdapter.kt      |   7 +-
 .../wallet/transactions/TransactionManager.kt      |   2 +-
 .../net/taler/wallet/transactions/Transactions.kt  |  11 +-
 .../java/net/taler/wallet/withdraw/TosSection.kt   |   7 ++
 .../net/taler/wallet/withdraw/WithdrawManager.kt   | 122 +++++++++++----------
 wallet/src/main/res/drawable/ic_error.xml          |   1 +
 .../src/main/res/drawable/transaction_refresh.xml  |   1 +
 .../src/main/res/drawable/transaction_refund.xml   |   1 +
 .../main/res/drawable/transaction_tip_accepted.xml |   1 +
 .../main/res/drawable/transaction_withdrawal.xml   |   1 +
 .../src/main/res/layout/list_item_transaction.xml  |   1 -
 .../net/taler/wallet/backend/WalletResponseTest.kt |  90 +++++++++++++++
 31 files changed, 595 insertions(+), 273 deletions(-)
 create mode 100644 
wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt
 copy taler-kotlin-common/src/nativeMain/kotlin/net/taler/common/Time.kt => 
wallet/src/main/java/net/taler/wallet/balances/BalanceResponse.kt (82%)
 create mode 100644 
wallet/src/test/java/net/taler/wallet/backend/WalletResponseTest.kt

diff --git a/merchant-lib/build.gradle b/merchant-lib/build.gradle
index 33e8379..5082253 100644
--- a/merchant-lib/build.gradle
+++ b/merchant-lib/build.gradle
@@ -56,4 +56,5 @@ dependencies {
     testImplementation 'junit:junit:4.13'
     testImplementation "io.ktor:ktor-client-mock-jvm:$ktor_version"
     testImplementation "io.ktor:ktor-client-logging-jvm:$ktor_version"
+    testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.8'
 }
diff --git a/merchant-lib/src/main/java/net/taler/merchantlib/MerchantApi.kt 
b/merchant-lib/src/main/java/net/taler/merchantlib/MerchantApi.kt
index c92d4d2..a4ca397 100644
--- a/merchant-lib/src/main/java/net/taler/merchantlib/MerchantApi.kt
+++ b/merchant-lib/src/main/java/net/taler/merchantlib/MerchantApi.kt
@@ -27,63 +27,81 @@ import io.ktor.client.request.post
 import io.ktor.http.ContentType.Application.Json
 import io.ktor.http.HttpHeaders.Authorization
 import io.ktor.http.contentType
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
 import kotlinx.serialization.json.Json
 import kotlinx.serialization.json.JsonConfiguration
 import net.taler.merchantlib.Response.Companion.response
 
-class MerchantApi(private val httpClient: HttpClient) {
+class MerchantApi(
+    private val httpClient: HttpClient = getDefaultHttpClient(),
+    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
+) {
 
-    suspend fun getConfig(baseUrl: String): Response<ConfigResponse> = 
response {
-        httpClient.get("$baseUrl/config") as ConfigResponse
+    suspend fun getConfig(baseUrl: String): Response<ConfigResponse> = 
withContext(ioDispatcher) {
+        response {
+            httpClient.get("$baseUrl/config") as ConfigResponse
+        }
     }
 
     suspend fun postOrder(
         merchantConfig: MerchantConfig,
         orderRequest: PostOrderRequest
-    ): Response<PostOrderResponse> = response {
-        httpClient.post(merchantConfig.urlFor("private/orders")) {
-            header(Authorization, "ApiKey ${merchantConfig.apiKey}")
-            contentType(Json)
-            body = orderRequest
-        } as PostOrderResponse
+    ): Response<PostOrderResponse> = withContext(ioDispatcher) {
+        response {
+            httpClient.post(merchantConfig.urlFor("private/orders")) {
+                header(Authorization, "ApiKey ${merchantConfig.apiKey}")
+                contentType(Json)
+                body = orderRequest
+            } as PostOrderResponse
+        }
     }
 
     suspend fun checkOrder(
         merchantConfig: MerchantConfig,
         orderId: String
-    ): Response<CheckPaymentResponse> = response {
-        httpClient.get(merchantConfig.urlFor("private/orders/$orderId")) {
-            header(Authorization, "ApiKey ${merchantConfig.apiKey}")
-        } as CheckPaymentResponse
+    ): Response<CheckPaymentResponse> = withContext(ioDispatcher) {
+        response {
+            httpClient.get(merchantConfig.urlFor("private/orders/$orderId")) {
+                header(Authorization, "ApiKey ${merchantConfig.apiKey}")
+            } as CheckPaymentResponse
+        }
     }
 
     suspend fun deleteOrder(
         merchantConfig: MerchantConfig,
         orderId: String
-    ): Response<Unit> = response {
-        httpClient.delete(merchantConfig.urlFor("private/orders/$orderId")) {
-            header(Authorization, "ApiKey ${merchantConfig.apiKey}")
-        } as Unit
+    ): Response<Unit> = withContext(ioDispatcher) {
+        response {
+            
httpClient.delete(merchantConfig.urlFor("private/orders/$orderId")) {
+                header(Authorization, "ApiKey ${merchantConfig.apiKey}")
+            } as Unit
+        }
     }
 
-    suspend fun getOrderHistory(merchantConfig: MerchantConfig): 
Response<OrderHistory> = response {
-        httpClient.get(merchantConfig.urlFor("private/orders")) {
-            header(Authorization, "ApiKey ${merchantConfig.apiKey}")
-        } as OrderHistory
-    }
+    suspend fun getOrderHistory(merchantConfig: MerchantConfig): 
Response<OrderHistory> =
+        withContext(ioDispatcher) {
+            response {
+                httpClient.get(merchantConfig.urlFor("private/orders")) {
+                    header(Authorization, "ApiKey ${merchantConfig.apiKey}")
+                } as OrderHistory
+            }
+        }
 
     suspend fun giveRefund(
         merchantConfig: MerchantConfig,
         orderId: String,
         request: RefundRequest
-    ): Response<RefundResponse> = response {
-        
httpClient.post(merchantConfig.urlFor("private/orders/$orderId/refund")) {
-            header(Authorization, "ApiKey ${merchantConfig.apiKey}")
-            contentType(Json)
-            body = request
-        } as RefundResponse
+    ): Response<RefundResponse> = withContext(ioDispatcher) {
+        response {
+            
httpClient.post(merchantConfig.urlFor("private/orders/$orderId/refund")) {
+                header(Authorization, "ApiKey ${merchantConfig.apiKey}")
+                contentType(Json)
+                body = request
+            } as RefundResponse
+        }
     }
-
 }
 
 fun getDefaultHttpClient(): HttpClient = HttpClient(OkHttp) {
diff --git a/merchant-lib/src/main/java/net/taler/merchantlib/Response.kt 
b/merchant-lib/src/main/java/net/taler/merchantlib/Response.kt
index 65a12a9..fb48b46 100644
--- a/merchant-lib/src/main/java/net/taler/merchantlib/Response.kt
+++ b/merchant-lib/src/main/java/net/taler/merchantlib/Response.kt
@@ -25,7 +25,6 @@ import kotlinx.serialization.Serializable
 class Response<out T> private constructor(
     private val value: Any?
 ) {
-
     companion object {
         suspend fun <T> response(request: suspend () -> T): Response<T> {
             return try {
@@ -45,7 +44,7 @@ class Response<out T> private constructor(
 
     val isFailure: Boolean get() = value is Failure
 
-    suspend fun handle(onFailure: ((String) -> Any)? = null, onSuccess: ((T) 
-> Any)? = null) {
+    suspend fun handle(onFailure: ((String) -> Unit)? = null, onSuccess: ((T) 
-> Unit)? = null) {
         if (value is Failure) onFailure?.let { it(getFailureString(value)) }
         else onSuccess?.let {
             @Suppress("UNCHECKED_CAST")
@@ -86,5 +85,4 @@ class Response<out T> private constructor(
         val code: Int?,
         val hint: String?
     )
-
 }
diff --git 
a/merchant-lib/src/test/java/net/taler/merchantlib/MerchantApiTest.kt 
b/merchant-lib/src/test/java/net/taler/merchantlib/MerchantApiTest.kt
index f9f5e87..992af6f 100644
--- a/merchant-lib/src/test/java/net/taler/merchantlib/MerchantApiTest.kt
+++ b/merchant-lib/src/test/java/net/taler/merchantlib/MerchantApiTest.kt
@@ -17,7 +17,9 @@
 package net.taler.merchantlib
 
 import io.ktor.http.HttpStatusCode.Companion.NotFound
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestCoroutineDispatcher
+import kotlinx.coroutines.test.runBlockingTest
 import net.taler.common.Amount
 import net.taler.common.ContractProduct
 import net.taler.common.ContractTerms
@@ -28,9 +30,10 @@ import org.junit.Assert.assertEquals
 import org.junit.Assert.assertTrue
 import org.junit.Test
 
+@ExperimentalCoroutinesApi
 class MerchantApiTest {
 
-    private val api = MerchantApi(httpClient)
+    private val api = MerchantApi(httpClient, TestCoroutineDispatcher())
     private val merchantConfig = MerchantConfig(
         baseUrl = "http://example.net/";,
         instance = "testInstance",
@@ -39,7 +42,7 @@ class MerchantApiTest {
     private val orderId = "orderIdFoo"
 
     @Test
-    fun testGetConfig() = runBlocking {
+    fun testGetConfig() = runBlockingTest {
         httpClient.giveJsonResponse("https://backend.int.taler.net/config";) {
             """
             {
@@ -54,7 +57,7 @@ class MerchantApiTest {
     }
 
     @Test
-    fun testPostOrder() = runBlocking {
+    fun testPostOrder() = runBlockingTest {
         val product = ContractProduct(
             productId = "foo",
             description = "bar",
@@ -111,7 +114,7 @@ class MerchantApiTest {
     }
 
     @Test
-    fun testCheckOrder() = runBlocking {
+    fun testCheckOrder() = runBlockingTest {
         val unpaidResponse = CheckPaymentResponse.Unpaid(false, 
"http://taler.net/foo";)
         
httpClient.giveJsonResponse("http://example.net/instances/testInstance/private/orders/$orderId";)
 {
             """{
@@ -140,7 +143,7 @@ class MerchantApiTest {
     }
 
     @Test
-    fun testDeleteOrder() = runBlocking {
+    fun testDeleteOrder() = runBlockingTest {
         
httpClient.giveJsonResponse("http://example.net/instances/testInstance/private/orders/$orderId";)
 {
             "{}"
         }
@@ -163,7 +166,7 @@ class MerchantApiTest {
     }
 
     @Test
-    fun testGetOrderHistory() = runBlocking {
+    fun testGetOrderHistory() = runBlockingTest {
         
httpClient.giveJsonResponse("http://example.net/instances/testInstance/private/orders";)
 {
             """{  "orders": [
                     {
@@ -213,7 +216,7 @@ class MerchantApiTest {
     }
 
     @Test
-    fun testGiveRefund() = runBlocking {
+    fun testGiveRefund() = runBlockingTest {
         
httpClient.giveJsonResponse("http://example.net/instances/testInstance/private/orders/$orderId/refund";)
 {
             """{
                 "taler_refund_uri": "taler://refund/foo/bar"
@@ -227,5 +230,4 @@ class MerchantApiTest {
             assertEquals("taler://refund/foo/bar", it.talerRefundUri)
         }
     }
-
 }
diff --git 
a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt 
b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt
index c0b01a2..67e3685 100644
--- 
a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt
+++ 
b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt
@@ -30,6 +30,7 @@ import io.ktor.client.features.ClientRequestException
 import io.ktor.client.request.get
 import io.ktor.client.request.header
 import io.ktor.http.HttpHeaders.Authorization
+import io.ktor.http.HttpStatusCode.Companion.Unauthorized
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
@@ -114,7 +115,7 @@ class ConfigManager(
                 Log.e(TAG, "Error retrieving merchant config", e)
                 val msg = if (e is ClientRequestException) {
                     context.getString(
-                        if (e.response.status.value == 401) 
R.string.config_auth_error
+                        if (e.response.status == Unauthorized) 
R.string.config_auth_error
                         else R.string.config_error_network
                     )
                 } else {
@@ -145,7 +146,7 @@ class ConfigManager(
                 Log.e(TAG, "Error handling configuration by 
${receiver::class.java.simpleName}", e)
                 context.getString(R.string.config_error_unknown)
             }
-            if (result != null) {  // error
+            if (result != null) { // error
                 mConfigUpdateResult.postValue(ConfigUpdateResult.Error(result))
                 return
             }
@@ -178,7 +179,6 @@ class ConfigManager(
     private fun onNetworkError(msg: String) = scope.launch(Dispatchers.Main) {
         mConfigUpdateResult.value = ConfigUpdateResult.Error(msg)
     }
-
 }
 
 sealed class ConfigUpdateResult {
diff --git 
a/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt
 
b/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt
index aabe4cc..d880eaa 100644
--- 
a/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt
+++ 
b/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt
@@ -20,8 +20,8 @@ import androidx.annotation.UiThread
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
+import net.taler.common.assertUiThread
 import net.taler.merchantlib.MerchantApi
 import net.taler.merchantlib.OrderHistoryEntry
 import net.taler.merchantpos.config.ConfigManager
@@ -44,20 +44,19 @@ class HistoryManager(
     val items: LiveData<HistoryResult> = mItems
 
     @UiThread
-    internal fun fetchHistory() {
+    internal fun fetchHistory() = scope.launch {
         mIsLoading.value = true
         val merchantConfig = configManager.merchantConfig!!
-        scope.launch(Dispatchers.IO) {
-            api.getOrderHistory(merchantConfig).handle(::onHistoryError) {
-                mIsLoading.postValue(false)
-                mItems.postValue(HistoryResult.Success(it.orders))
-            }
+        api.getOrderHistory(merchantConfig).handle(::onHistoryError) {
+            assertUiThread()
+            mIsLoading.value = false
+            mItems.value = HistoryResult.Success(it.orders)
         }
     }
 
     private fun onHistoryError(msg: String) {
-        mIsLoading.postValue(false)
-        mItems.postValue(HistoryResult.Error(msg))
+        assertUiThread()
+        mIsLoading.value = false
+        mItems.value = HistoryResult.Error(msg)
     }
-
 }
diff --git 
a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt
 
b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt
index 6bab0e6..b39355a 100644
--- 
a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt
+++ 
b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt
@@ -23,13 +23,14 @@ import androidx.annotation.UiThread
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import net.taler.common.Duration
+import net.taler.common.assertUiThread
 import net.taler.merchantlib.CheckPaymentResponse
 import net.taler.merchantlib.MerchantApi
 import net.taler.merchantlib.PostOrderRequest
-import net.taler.merchantlib.PostOrderResponse
 import net.taler.merchantpos.MainActivity.Companion.TAG
 import net.taler.merchantpos.R
 import net.taler.merchantpos.config.ConfigManager
@@ -50,12 +51,16 @@ class PaymentManager(
 
     private val mPayment = MutableLiveData<Payment>()
     val payment: LiveData<Payment> = mPayment
+    private var checkJob: Job? = null
 
-    private val checkTimer = object : CountDownTimer(TIMEOUT, CHECK_INTERVAL) {
+    private val checkTimer: CountDownTimer = object : CountDownTimer(TIMEOUT, 
CHECK_INTERVAL) {
         override fun onTick(millisUntilFinished: Long) {
             val orderId = payment.value?.orderId
             if (orderId == null) cancel()
-            else checkPayment(orderId)
+            // only start new job if old one doesn't exist or is complete
+            else if (checkJob == null || checkJob?.isCompleted == true) {
+                checkJob = checkPayment(orderId)
+            }
         }
 
         override fun onFinish() {
@@ -64,44 +69,39 @@ class PaymentManager(
     }
 
     @UiThread
-    fun createPayment(order: Order) {
+    fun createPayment(order: Order) = scope.launch {
         val merchantConfig = configManager.merchantConfig!!
         mPayment.value = Payment(order, order.summary, 
configManager.currency!!)
-        scope.launch(Dispatchers.IO) {
-            val request = PostOrderRequest(
-                contractTerms = order.toContractTerms(),
-                refundDelay = Duration(HOURS.toMillis(1))
-            )
-            val response = api.postOrder(merchantConfig, request)
-            response.handle(::onNetworkError, ::onOrderCreated)
+        val request = PostOrderRequest(
+            contractTerms = order.toContractTerms(),
+            refundDelay = Duration(HOURS.toMillis(1))
+        )
+        api.postOrder(merchantConfig, request).handle(::onNetworkError) { 
orderResponse ->
+            assertUiThread()
+            mPayment.value = mPayment.value!!.copy(orderId = 
orderResponse.orderId)
+            checkTimer.start()
         }
     }
 
-    private fun onOrderCreated(orderResponse: PostOrderResponse) = 
scope.launch(Dispatchers.Main) {
-        mPayment.value = mPayment.value!!.copy(orderId = orderResponse.orderId)
-        checkTimer.start()
-    }
-
-    private fun checkPayment(orderId: String) {
+    private fun checkPayment(orderId: String) = scope.launch {
         val merchantConfig = configManager.merchantConfig!!
-        scope.launch(Dispatchers.IO) {
-            val response = api.checkOrder(merchantConfig, orderId)
-            response.handle(::onNetworkError, ::onPaymentChecked)
-        }
-    }
-
-    private fun onPaymentChecked(response: CheckPaymentResponse) = 
scope.launch(Dispatchers.Main) {
-        val currentValue = requireNotNull(mPayment.value)
-        if (response.paid) {
-            mPayment.value = currentValue.copy(paid = true)
-            checkTimer.cancel()
-        } else if (currentValue.talerPayUri == null) {
-            response as CheckPaymentResponse.Unpaid
-            mPayment.value = currentValue.copy(talerPayUri = 
response.talerPayUri)
+        api.checkOrder(merchantConfig, orderId).handle(::onNetworkError) { 
response ->
+            assertUiThread()
+            if (!isActive) return@handle // don't continue if job was cancelled
+            val currentValue = requireNotNull(mPayment.value)
+            if (response.paid) {
+                mPayment.value = currentValue.copy(paid = true)
+                checkTimer.cancel()
+            } else if (currentValue.talerPayUri == null) {
+                response as CheckPaymentResponse.Unpaid
+                mPayment.value = currentValue.copy(talerPayUri = 
response.talerPayUri)
+            }
         }
     }
 
-    private fun onNetworkError(error: String) = scope.launch(Dispatchers.Main) 
{
+    private fun onNetworkError(error: String) {
+        assertUiThread()
+        Log.d(TAG, "Network error: $error")
         cancelPayment(error)
     }
 
@@ -112,14 +112,14 @@ class PaymentManager(
         mPayment.value?.let { payment ->
             if (!payment.paid && payment.error != null) payment.orderId?.let { 
orderId ->
                 Log.d(TAG, "Deleting cancelled and unpaid order $orderId")
-                scope.launch(Dispatchers.IO) {
+                scope.launch {
                     api.deleteOrder(merchantConfig, orderId)
                 }
             }
         }
-
         mPayment.value = mPayment.value!!.copy(error = error)
         checkTimer.cancel()
+        checkJob?.isCancelled
+        checkJob = null
     }
-
 }
diff --git 
a/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundManager.kt 
b/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundManager.kt
index ea2d398..25c7c5e 100644
--- 
a/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundManager.kt
+++ 
b/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundManager.kt
@@ -20,9 +20,9 @@ import androidx.annotation.UiThread
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 import net.taler.common.Amount
+import net.taler.common.assertUiThread
 import net.taler.merchantlib.MerchantApi
 import net.taler.merchantlib.OrderHistoryEntry
 import net.taler.merchantlib.RefundRequest
@@ -65,27 +65,25 @@ class RefundManager(
     }
 
     @UiThread
-    internal fun refund(item: OrderHistoryEntry, amount: Amount, reason: 
String) {
+    internal fun refund(item: OrderHistoryEntry, amount: Amount, reason: 
String) = scope.launch {
         val merchantConfig = configManager.merchantConfig!!
         val request = RefundRequest(amount, reason)
-        scope.launch(Dispatchers.IO) {
-            api.giveRefund(merchantConfig, item.orderId, 
request).handle(::onRefundError) {
-                val result = RefundResult.Success(
-                    refundUri = it.talerRefundUri,
-                    item = item,
-                    amount = amount,
-                    reason = reason
-                )
-                mRefundResult.postValue(result)
-            }
+        api.giveRefund(merchantConfig, item.orderId, 
request).handle(::onRefundError) {
+            assertUiThread()
+            mRefundResult.value = RefundResult.Success(
+                refundUri = it.talerRefundUri,
+                item = item,
+                amount = amount,
+                reason = reason
+            )
         }
     }
 
     @UiThread
     private fun onRefundError(msg: String) {
+        assertUiThread()
         if (msg.contains("2602")) {
             mRefundResult.postValue(RefundResult.AlreadyRefunded)
         } else mRefundResult.postValue(RefundResult.Error(msg))
     }
-
 }
diff --git a/taler-kotlin-android/build.gradle 
b/taler-kotlin-android/build.gradle
index d6d6003..20590e0 100644
--- a/taler-kotlin-android/build.gradle
+++ b/taler-kotlin-android/build.gradle
@@ -53,15 +53,14 @@ dependencies {
     api project(":taler-kotlin-common")
 
     implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
-    implementation 'androidx.appcompat:appcompat:1.1.0'
-    implementation 'androidx.core:core-ktx:1.3.0'
+    implementation 'androidx.appcompat:appcompat:1.2.0'
+    implementation 'androidx.core:core-ktx:1.3.1'
 
     // Navigation
     implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
     implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
 
     // ViewModel and LiveData
-    def lifecycle_version = "2.2.0"
     implementation 
"androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
 
     // QR codes
@@ -71,7 +70,7 @@ dependencies {
     api "org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.20.0"
     implementation "com.fasterxml.jackson.module:jackson-module-kotlin:2.10.2"
 
-    lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha'
+    lintPublish 'com.github.thirdegg:lint-rules:0.0.4-alpha'
 
     testImplementation 'junit:junit:4.13'
     testImplementation 'org.json:json:20190722'
diff --git a/wallet/build.gradle b/wallet/build.gradle
index ef5ddfa..329e271 100644
--- a/wallet/build.gradle
+++ b/wallet/build.gradle
@@ -20,10 +20,11 @@ plugins {
     id "com.android.application"
     id "kotlin-android"
     id "kotlin-android-extensions"
+    id 'kotlinx-serialization'
     id "de.undercouch.download"
 }
 
-def walletCoreVersion = "v0.7.1-dev.18"
+def walletCoreVersion = "v0.7.1-dev.19"
 
 static def versionCodeEpoch() {
     return (new Date().getTime() / 1000).toInteger()
@@ -47,7 +48,7 @@ android {
         minSdkVersion 24
         targetSdkVersion 29
         versionCode 6
-        versionName "0.7.1.dev.18"
+        versionName "0.7.1.dev.19"
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
         buildConfigField "String", "WALLET_CORE_VERSION", 
"\"$walletCoreVersion\""
     }
@@ -102,7 +103,7 @@ dependencies {
     implementation 'net.taler:akono:0.1'
 
     implementation 'androidx.preference:preference:1.1.1'
-    implementation 'com.google.android.material:material:1.1.0'
+    implementation 'com.google.android.material:material:1.2.0'
     implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
 
     // Lists and Selection
diff --git a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt 
b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt
index 2c5e318..24a8f1e 100644
--- a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt
+++ b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt
@@ -27,7 +27,8 @@ import androidx.lifecycle.viewModelScope
 import 
com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES
 import com.fasterxml.jackson.databind.ObjectMapper
 import com.fasterxml.jackson.module.kotlin.KotlinModule
-import com.fasterxml.jackson.module.kotlin.readValue
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
 import net.taler.common.Amount
 import net.taler.common.AmountMixin
 import net.taler.common.Event
@@ -37,6 +38,7 @@ import net.taler.common.assertUiThread
 import net.taler.common.toEvent
 import net.taler.wallet.backend.WalletBackendApi
 import net.taler.wallet.balances.BalanceItem
+import net.taler.wallet.balances.BalanceResponse
 import net.taler.wallet.exchanges.ExchangeManager
 import net.taler.wallet.payment.PaymentManager
 import net.taler.wallet.pending.PendingOperationsManager
@@ -68,15 +70,19 @@ class MainViewModel(val app: Application) : 
AndroidViewModel(app) {
     var merchantVersion: String? = null
         private set
 
-    private val walletBackendApi = WalletBackendApi(app, {
-        // nothing to do when we connect, balance will be requested by 
BalanceFragment in onStart()
-    }) { payload ->
+    private val mapper = ObjectMapper()
+        .registerModule(KotlinModule())
+        .configure(FAIL_ON_UNKNOWN_PROPERTIES, false)
+        .addMixIn(Amount::class.java, AmountMixin::class.java)
+        .addMixIn(Timestamp::class.java, TimestampMixin::class.java)
+
+    private val api = WalletBackendApi(app) { payload ->
         if (payload.optString("operation") == "init") {
             val result = payload.getJSONObject("result")
             val versions = result.getJSONObject("supported_protocol_versions")
             exchangeVersion = versions.getString("exchange")
             merchantVersion = versions.getString("merchant")
-        } else if (payload.getString("type") != "waiting-for-retry") {  // 
ignore ping
+        } else if (payload.getString("type") != "waiting-for-retry") { // 
ignore ping
             Log.i(TAG, "Received notification from wallet-core: 
${payload.toString(2)}")
             loadBalances()
             if (payload.optString("type") in transactionNotifications) {
@@ -92,20 +98,12 @@ class MainViewModel(val app: Application) : 
AndroidViewModel(app) {
         }
     }
 
-    private val mapper = ObjectMapper()
-        .registerModule(KotlinModule())
-        .configure(FAIL_ON_UNKNOWN_PROPERTIES, false)
-        .addMixIn(Amount::class.java, AmountMixin::class.java)
-        .addMixIn(Timestamp::class.java, TimestampMixin::class.java)
-
-    val withdrawManager = WithdrawManager(walletBackendApi, mapper)
-    val paymentManager = PaymentManager(walletBackendApi, mapper)
-    val pendingOperationsManager: PendingOperationsManager =
-        PendingOperationsManager(walletBackendApi)
-    val transactionManager: TransactionManager =
-        TransactionManager(walletBackendApi, viewModelScope, mapper)
-    val refundManager = RefundManager(walletBackendApi)
-    val exchangeManager: ExchangeManager = ExchangeManager(walletBackendApi, 
mapper)
+    val withdrawManager = WithdrawManager(api, viewModelScope)
+    val paymentManager = PaymentManager(api, viewModelScope, mapper)
+    val pendingOperationsManager: PendingOperationsManager = 
PendingOperationsManager(api)
+    val transactionManager: TransactionManager = TransactionManager(api, 
viewModelScope, mapper)
+    val refundManager = RefundManager(api)
+    val exchangeManager: ExchangeManager = ExchangeManager(api, mapper)
 
     private val mTransactionsEvent = MutableLiveData<Event<String>>()
     val transactionsEvent: LiveData<Event<String>> = mTransactionsEvent
@@ -118,20 +116,21 @@ class MainViewModel(val app: Application) : 
AndroidViewModel(app) {
     val lastBackup: LiveData<Long> = mLastBackup
 
     override fun onCleared() {
-        walletBackendApi.destroy()
+        api.destroy()
         super.onCleared()
     }
 
     @UiThread
-    fun loadBalances() {
+    fun loadBalances(): Job = viewModelScope.launch {
         showProgressBar.value = true
-        walletBackendApi.sendRequest("getBalances") { isError, result ->
-            if (isError) {
-                Log.e(TAG, "Error retrieving balances: ${result.toString(2)}")
-                return@sendRequest
-            }
-            mBalances.value = mapper.readValue(result.getString("balances"))
-            showProgressBar.value = false
+        val response = api.request("getBalances", BalanceResponse.serializer())
+        showProgressBar.value = false
+        response.onError {
+            // TODO expose in UI
+            Log.e(TAG, "Error retrieving balances: $it")
+        }
+        response.onSuccess {
+            mBalances.value = it.balances
         }
     }
 
@@ -145,22 +144,22 @@ class MainViewModel(val app: Application) : 
AndroidViewModel(app) {
 
     @UiThread
     fun dangerouslyReset() {
-        walletBackendApi.sendRequest("reset")
+        api.sendRequest("reset")
         withdrawManager.testWithdrawalInProgress.value = false
         mBalances.value = emptyList()
     }
 
     fun startTunnel() {
-        walletBackendApi.sendRequest("startTunnel")
+        api.sendRequest("startTunnel")
     }
 
     fun stopTunnel() {
-        walletBackendApi.sendRequest("stopTunnel")
+        api.sendRequest("stopTunnel")
     }
 
     fun tunnelResponse(resp: String) {
         val respJson = JSONObject(resp)
-        walletBackendApi.sendRequest("tunnelResponse", respJson)
+        api.sendRequest("tunnelResponse", respJson)
     }
 
 }
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 51b3419..5ca2255 100644
--- a/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt
+++ b/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt
@@ -14,7 +14,6 @@
  * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
  */
 
-
 package net.taler.wallet.backend
 
 import android.app.Application
@@ -27,21 +26,37 @@ import android.os.IBinder
 import android.os.Message
 import android.os.Messenger
 import android.util.Log
-import android.util.SparseArray
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.kotlin.readValue
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonConfiguration
+import net.taler.wallet.backend.WalletBackendService.Companion.MSG_COMMAND
+import net.taler.wallet.backend.WalletBackendService.Companion.MSG_NOTIFY
+import net.taler.wallet.backend.WalletBackendService.Companion.MSG_REPLY
+import 
net.taler.wallet.backend.WalletBackendService.Companion.MSG_SUBSCRIBE_NOTIFY
 import org.json.JSONObject
 import java.lang.ref.WeakReference
 import java.util.LinkedList
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
 
 class WalletBackendApi(
     private val app: Application,
-    private val onConnected: (() -> Unit),
     private val notificationHandler: ((payload: JSONObject) -> Unit)
 ) {
-
+    private val json = Json(
+        JsonConfiguration.Stable.copy(ignoreUnknownKeys = true)
+    )
     private var walletBackendMessenger: Messenger? = null
     private val queuedMessages = LinkedList<Message>()
-    private val handlers = SparseArray<(isError: Boolean, message: JSONObject) 
-> Unit>()
-    private var nextRequestID = 1
+    private val handlers = ConcurrentHashMap<Int, (isError: Boolean, message: 
JSONObject) -> Unit>()
+    private var nextRequestID = AtomicInteger(0)
+    private val incomingMessenger = Messenger(IncomingHandler(this))
 
     private val walletBackendConn = object : ServiceConnection {
         override fun onServiceDisconnected(p0: ComponentName?) {
@@ -54,10 +69,15 @@ class WalletBackendApi(
             val bm = Messenger(binder)
             walletBackendMessenger = bm
             pumpQueue(bm)
-            val msg = Message.obtain(null, 
WalletBackendService.MSG_SUBSCRIBE_NOTIFY)
+            val msg = Message.obtain(null, MSG_SUBSCRIBE_NOTIFY)
             msg.replyTo = incomingMessenger
             bm.send(msg)
-            onConnected.invoke()
+        }
+    }
+
+    init {
+        Intent(app, WalletBackendService::class.java).also { intent ->
+            app.bindService(intent, walletBackendConn, 
Context.BIND_AUTO_CREATE)
         }
     }
 
@@ -66,11 +86,11 @@ class WalletBackendApi(
         override fun handleMessage(msg: Message) {
             val api = weakApi.get() ?: return
             when (msg.what) {
-                WalletBackendService.MSG_REPLY -> {
+                MSG_REPLY -> {
                     val requestID = msg.data.getInt("requestID", 0)
                     val operation = msg.data.getString("operation", "??")
                     Log.i(TAG, "got reply for operation $operation 
($requestID)")
-                    val h = api.handlers.get(requestID)
+                    val h = api.handlers.remove(requestID)
                     if (h == null) {
                         Log.e(TAG, "request ID not associated with a handler")
                         return
@@ -84,7 +104,7 @@ class WalletBackendApi(
                     val json = JSONObject(response)
                     h(isError, json)
                 }
-                WalletBackendService.MSG_NOTIFY -> {
+                MSG_NOTIFY -> {
                     val payloadStr = msg.data.getString("payload")
                     if (payloadStr == null) {
                         Log.e(TAG, "Notification had no payload: $msg")
@@ -97,14 +117,6 @@ class WalletBackendApi(
         }
     }
 
-    private val incomingMessenger = Messenger(IncomingHandler(this))
-
-    init {
-        Intent(app, WalletBackendService::class.java).also { intent ->
-            app.bindService(intent, walletBackendConn, 
Context.BIND_AUTO_CREATE)
-        }
-    }
-
     private fun pumpQueue(bm: Messenger) {
         while (true) {
             val msg = queuedMessages.pollFirst() ?: return
@@ -112,16 +124,15 @@ class WalletBackendApi(
         }
     }
 
-
     fun sendRequest(
         operation: String,
         args: JSONObject? = null,
         onResponse: (isError: Boolean, message: JSONObject) -> Unit = { _, _ 
-> }
     ) {
-        val requestID = nextRequestID++
+        val requestID = nextRequestID.incrementAndGet()
         Log.i(TAG, "sending request for operation $operation ($requestID)")
-        val msg = Message.obtain(null, WalletBackendService.MSG_COMMAND)
-        handlers.put(requestID, onResponse)
+        val msg = Message.obtain(null, MSG_COMMAND)
+        handlers[requestID] = onResponse
         msg.replyTo = incomingMessenger
         val data = msg.data
         data.putString("operation", operation)
@@ -137,6 +148,45 @@ class WalletBackendApi(
         }
     }
 
+    suspend fun <T> request(
+        operation: String,
+        serializer: KSerializer<T>? = null,
+        args: (JSONObject.() -> JSONObject)? = null
+    ): WalletResponse<T> = withContext(Dispatchers.Default) {
+        suspendCoroutine<WalletResponse<T>> { cont ->
+            sendRequest(operation, args?.invoke(JSONObject())) { isError, 
message ->
+                val response = if (isError) {
+                    val error = json.parse(WalletErrorInfo.serializer(), 
message.toString())
+                    WalletResponse.Error<T>(error)
+                } else {
+                    @Suppress("UNCHECKED_CAST") // if serializer is null, T 
must be Unit
+                    val t: T = serializer?.let { json.parse(serializer, 
message.toString()) } ?: Unit as T
+                    WalletResponse.Success(t)
+                }
+                cont.resume(response)
+            }
+        }
+    }
+
+    suspend inline fun <reified T> request(
+        operation: String,
+        mapper: ObjectMapper,
+        noinline args: (JSONObject.() -> JSONObject)? = null
+    ): WalletResponse<T> = withContext(Dispatchers.Default) {
+        suspendCoroutine<WalletResponse<T>> { cont ->
+            sendRequest(operation, args?.invoke(JSONObject())) { isError, 
message ->
+                val response = if (isError) {
+                    val error: WalletErrorInfo = 
mapper.readValue(message.toString())
+                    WalletResponse.Error<T>(error)
+                } else {
+                    val t: T = mapper.readValue(message.toString())
+                    WalletResponse.Success(t)
+                }
+                cont.resume(response)
+            }
+        }
+    }
+
     fun destroy() {
         // FIXME: implement this!
     }
diff --git a/wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt 
b/wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt
new file mode 100644
index 0000000..ab3d42e
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt
@@ -0,0 +1,117 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2020 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.backend
+
+import com.fasterxml.jackson.core.JsonParser
+import com.fasterxml.jackson.databind.DeserializationContext
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer
+import kotlinx.serialization.Decoder
+import kotlinx.serialization.Encoder
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.PrimitiveDescriptor
+import kotlinx.serialization.PrimitiveKind.STRING
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonInput
+import kotlinx.serialization.json.JsonObject
+import org.json.JSONObject
+
+
+@Serializable
+sealed class WalletResponse<T> {
+    @Serializable
+    @SerialName("response")
+    data class Success<T>(
+        val result: T
+    ) : WalletResponse<T>()
+
+    @Serializable
+    @SerialName("error")
+    data class Error<T>(
+        val error: WalletErrorInfo
+    ) : WalletResponse<T>()
+
+    fun onSuccess(block: (result: T) -> Unit): WalletResponse<T> {
+        if (this is Success) block(this.result)
+        return this
+    }
+
+    fun onError(block: (result: WalletErrorInfo) -> Unit): WalletResponse<T> {
+        if (this is Error) block(this.error)
+        return this
+    }
+}
+
+@Serializable
+data class WalletErrorInfo(
+    // Numeric error code defined defined in the
+    // GANA gnu-taler-error-codes registry.
+    val talerErrorCode: Int,
+
+    // English description of the error code.
+    val talerErrorHint: String,
+
+    // English diagnostic message that can give details
+    // for the instance of the error.
+    val message: String,
+
+    // Error details, type depends on talerErrorCode
+    @Serializable(JSONObjectDeserializer::class)
+    @JsonDeserialize(using = JsonObjectDeserializer::class)
+    val details: JSONObject?
+) {
+    val userFacingMsg: String
+        get() {
+            return StringBuilder().apply {
+                append(talerErrorCode)
+                append(" ")
+                append(message)
+                details?.let { details ->
+                    details.optJSONObject("errorResponse")?.let { 
errorResponse ->
+                        append("\n\n")
+                        append(errorResponse.optString("code"))
+                        append(" ")
+                        append(errorResponse.optString("hint"))
+                    }
+                }
+            }.toString()
+        }
+}
+
+class JSONObjectDeserializer : KSerializer<JSONObject> {
+
+    override val descriptor = PrimitiveDescriptor("JSONObjectDeserializer", 
STRING)
+
+    override fun deserialize(decoder: Decoder): JSONObject {
+        val input = decoder as JsonInput
+        val tree = input.decodeJson() as JsonObject
+        return JSONObject(tree.toString())
+    }
+
+    override fun serialize(encoder: Encoder, value: JSONObject) {
+        error("not supported")
+    }
+}
+
+class JsonObjectDeserializer : 
StdDeserializer<JSONObject>(JSONObject::class.java) {
+    override fun deserialize(p: JsonParser, ctxt: DeserializationContext): 
JSONObject {
+        val node: JsonNode = p.codec.readTree(p)
+        return JSONObject(node.toString())
+    }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalanceAdapter.kt 
b/wallet/src/main/java/net/taler/wallet/balances/BalanceAdapter.kt
index c090e75..24ee1a1 100644
--- a/wallet/src/main/java/net/taler/wallet/balances/BalanceAdapter.kt
+++ b/wallet/src/main/java/net/taler/wallet/balances/BalanceAdapter.kt
@@ -24,10 +24,12 @@ import android.view.ViewGroup
 import android.widget.TextView
 import androidx.recyclerview.widget.RecyclerView
 import androidx.recyclerview.widget.RecyclerView.Adapter
+import kotlinx.serialization.Serializable
 import net.taler.common.Amount
 import net.taler.wallet.R
 import net.taler.wallet.balances.BalanceAdapter.BalanceViewHolder
 
+@Serializable
 data class BalanceItem(
     val available: Amount,
     val pendingIncoming: Amount,
diff --git a/taler-kotlin-common/src/nativeMain/kotlin/net/taler/common/Time.kt 
b/wallet/src/main/java/net/taler/wallet/balances/BalanceResponse.kt
similarity index 82%
copy from taler-kotlin-common/src/nativeMain/kotlin/net/taler/common/Time.kt
copy to wallet/src/main/java/net/taler/wallet/balances/BalanceResponse.kt
index 8a4091a..d1a111f 100644
--- a/taler-kotlin-common/src/nativeMain/kotlin/net/taler/common/Time.kt
+++ b/wallet/src/main/java/net/taler/wallet/balances/BalanceResponse.kt
@@ -14,10 +14,11 @@
  * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
  */
 
-package net.taler.common
+package net.taler.wallet.balances
 
-import kotlin.system.getTimeMillis
+import kotlinx.serialization.Serializable
 
-actual fun nowMillis(): Long {
-    return getTimeMillis()
-}
+@Serializable
+data class BalanceResponse(
+    val balances: List<BalanceItem>
+)
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 189f444..17ac50f 100644
--- a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt
+++ b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt
@@ -24,10 +24,12 @@ import android.widget.TextView
 import androidx.appcompat.widget.PopupMenu
 import androidx.recyclerview.widget.RecyclerView
 import androidx.recyclerview.widget.RecyclerView.Adapter
+import kotlinx.serialization.Serializable
 import net.taler.wallet.R
 import net.taler.wallet.cleanExchange
 import net.taler.wallet.exchanges.ExchangeAdapter.ExchangeItemViewHolder
 
+@Serializable
 data class ExchangeItem(
     val exchangeBaseUrl: String,
     val currency: String,
diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt 
b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt
index db21da4..041fcd3 100644
--- a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt
+++ b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt
@@ -22,11 +22,13 @@ import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import com.fasterxml.jackson.databind.ObjectMapper
 import com.fasterxml.jackson.module.kotlin.readValue
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
 import net.taler.common.Amount
 import net.taler.common.ContractTerms
 import net.taler.wallet.TAG
 import net.taler.wallet.backend.WalletBackendApi
-import net.taler.wallet.getErrorString
+import net.taler.wallet.backend.WalletErrorInfo
 import net.taler.wallet.payment.PayStatus.AlreadyPaid
 import net.taler.wallet.payment.PayStatus.InsufficientBalance
 import net.taler.wallet.payment.PreparePayResponse.AlreadyConfirmedResponse
@@ -47,14 +49,20 @@ sealed class PayStatus {
         val amountEffective: Amount
     ) : PayStatus()
 
-    data class InsufficientBalance(val contractTerms: ContractTerms) : 
PayStatus()
+    data class InsufficientBalance(
+        val contractTerms: ContractTerms,
+        val amountRaw: Amount
+    ) : PayStatus()
+
+    // TODO bring user to fulfilment URI
     object AlreadyPaid : PayStatus()
     data class Error(val error: String) : PayStatus()
     data class Success(val currency: String) : PayStatus()
 }
 
 class PaymentManager(
-    private val walletBackendApi: WalletBackendApi,
+    private val api: WalletBackendApi,
+    private val scope: CoroutineScope,
     private val mapper: ObjectMapper
 ) {
 
@@ -65,21 +73,21 @@ class PaymentManager(
     internal val detailsShown: LiveData<Boolean> = mDetailsShown
 
     @UiThread
-    fun preparePay(url: String) {
+    fun preparePay(url: String) = scope.launch {
         mPayStatus.value = PayStatus.Loading
         mDetailsShown.value = false
-
-        val args = JSONObject(mapOf("talerPayUri" to url))
-        walletBackendApi.sendRequest("preparePay", args) { isError, result ->
-            if (isError) {
-                handleError("preparePay", getErrorString(result))
-                return@sendRequest
-            }
-            val response: PreparePayResponse = 
mapper.readValue(result.toString())
-            Log.e(TAG, "PreparePayResponse $response")
+        api.request<PreparePayResponse>("preparePay", mapper) {
+            put("talerPayUri", url)
+        }.onError {
+            handleError("preparePay", it)
+        }.onSuccess { response ->
+            Log.e(TAG, "PreparePayResponse $response") // TODO remove
             mPayStatus.value = when (response) {
                 is PaymentPossibleResponse -> response.toPayStatusPrepared()
-                is InsufficientBalanceResponse -> 
InsufficientBalance(response.contractTerms)
+                is InsufficientBalanceResponse -> InsufficientBalance(
+                    response.contractTerms,
+                    response.amountRaw
+                )
                 is AlreadyConfirmedResponse -> AlreadyPaid
             }
         }
@@ -99,13 +107,12 @@ class PaymentManager(
         return terms
     }
 
-    fun confirmPay(proposalId: String, currency: String) {
-        val args = JSONObject(mapOf("proposalId" to proposalId))
-        walletBackendApi.sendRequest("confirmPay", args) { isError, result ->
-            if (isError) {
-                handleError("preparePay", getErrorString(result))
-                return@sendRequest
-            }
+    fun confirmPay(proposalId: String, currency: String) = scope.launch {
+        api.request("confirmPay", ConfirmPayResult.serializer()) {
+            put("proposalId", proposalId)
+        }.onError {
+            handleError("confirmPay", it)
+        }.onSuccess {
             mPayStatus.postValue(PayStatus.Success(currency))
         }
     }
@@ -119,17 +126,14 @@ class PaymentManager(
         resetPayStatus()
     }
 
-    internal fun abortProposal(proposalId: String) {
-        val args = JSONObject(mapOf("proposalId" to proposalId))
-
+    internal fun abortProposal(proposalId: String) = scope.launch {
         Log.i(TAG, "aborting proposal")
-
-        walletBackendApi.sendRequest("abortProposal", args) { isError, result 
->
-            if (isError) {
-                handleError("abortProposal", getErrorString(result))
-                Log.e(TAG, "received error response to abortProposal")
-                return@sendRequest
-            }
+        api.request<String>("abortProposal", mapper) {
+            put("proposalId", proposalId)
+        }.onError {
+            Log.e(TAG, "received error response to abortProposal")
+            handleError("abortProposal", it)
+        }.onSuccess {
             mPayStatus.postValue(PayStatus.None)
         }
     }
@@ -145,9 +149,9 @@ class PaymentManager(
         mPayStatus.value = PayStatus.None
     }
 
-    private fun handleError(operation: String, msg: String) {
-        Log.e(TAG, "got $operation error result $msg")
-        mPayStatus.value = PayStatus.Error(msg)
+    private fun handleError(operation: String, error: WalletErrorInfo) {
+        Log.e(TAG, "got $operation error result $error")
+        mPayStatus.value = PayStatus.Error(error.userFacingMsg)
     }
 
 }
diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt 
b/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt
index 1ff8867..120489d 100644
--- a/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt
+++ b/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt
@@ -19,8 +19,11 @@ package net.taler.wallet.payment
 import com.fasterxml.jackson.annotation.JsonTypeInfo
 import com.fasterxml.jackson.annotation.JsonTypeInfo.Id.NAME
 import com.fasterxml.jackson.annotation.JsonTypeName
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
 import net.taler.common.Amount
 import net.taler.common.ContractTerms
+import net.taler.wallet.transactions.TransactionError
 
 @JsonTypeInfo(use = NAME, property = "status")
 sealed class PreparePayResponse(open val proposalId: String) {
@@ -42,6 +45,7 @@ sealed class PreparePayResponse(open val proposalId: String) {
     @JsonTypeName("insufficient-balance")
     data class InsufficientBalanceResponse(
         override val proposalId: String,
+        val amountRaw: Amount,
         val contractTerms: ContractTerms
     ) : PreparePayResponse(proposalId)
 
@@ -52,6 +56,8 @@ sealed class PreparePayResponse(open val proposalId: String) {
          * Did the payment succeed?
          */
         val paid: Boolean,
+        val amountRaw: Amount,
+        val amountEffective: Amount,
 
         /**
          * Redirect URL for the fulfillment page, only given if paid==true.
@@ -59,3 +65,14 @@ sealed class PreparePayResponse(open val proposalId: String) 
{
         val nextUrl: String?
     ) : PreparePayResponse(proposalId)
 }
+
+@Serializable
+sealed class ConfirmPayResult {
+    @Serializable
+    @SerialName("done")
+    data class Done(val nextUrl: String) : ConfirmPayResult()
+
+    @Serializable
+    @SerialName("pending")
+    data class Pending(val lastError: TransactionError) : ConfirmPayResult()
+}
diff --git 
a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt 
b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt
index ce2b6f7..40664e3 100644
--- a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt
@@ -96,7 +96,7 @@ class PromptPaymentFragment : Fragment(), 
ProductImageClickListener {
             is PayStatus.Prepared -> {
                 showLoading(false)
                 val fees = payStatus.amountEffective - payStatus.amountRaw
-                showOrder(payStatus.contractTerms, fees)
+                showOrder(payStatus.contractTerms, payStatus.amountRaw, fees)
                 confirmButton.isEnabled = true
                 confirmButton.setOnClickListener {
                     model.showProgressBar.value = true
@@ -110,7 +110,7 @@ class PromptPaymentFragment : Fragment(), 
ProductImageClickListener {
             }
             is PayStatus.InsufficientBalance -> {
                 showLoading(false)
-                showOrder(payStatus.contractTerms)
+                showOrder(payStatus.contractTerms, payStatus.amountRaw)
                 errorView.setText(R.string.payment_balance_insufficient)
                 errorView.fadeIn()
             }
@@ -142,11 +142,10 @@ class PromptPaymentFragment : Fragment(), 
ProductImageClickListener {
         }
     }
 
-    private fun showOrder(contractTerms: ContractTerms, totalFees: Amount? = 
null) {
+    private fun showOrder(contractTerms: ContractTerms, amount:Amount, 
totalFees: Amount? = null) {
         orderView.text = contractTerms.summary
         adapter.setItems(contractTerms.products)
         if (contractTerms.products.size == 1) 
paymentManager.toggleDetailsShown()
-        val amount = contractTerms.amount
         totalView.text = amount.toString()
         if (totalFees != null && !totalFees.isZero()) {
             feeView.text = getString(R.string.payment_fee, totalFees)
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 d670b74..f494b05 100644
--- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt
@@ -89,8 +89,11 @@ internal class TransactionAdapter(
             v.foreground = selectableForeground
             v.setOnClickListener { listener.onTransactionClicked(transaction) }
             v.isActivated = selected
-
-            icon.setImageResource(transaction.icon)
+            if (transaction.error == null) {
+                icon.setImageResource(transaction.icon)
+            } else {
+                icon.setImageResource(R.drawable.ic_error)
+            }
             title.text = transaction.getTitle(context)
             bindExtraInfo(transaction)
             time.text = transaction.timestamp.ms.toRelativeTime(context)
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 8ec3914..b9f86b3 100644
--- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt
@@ -87,7 +87,7 @@ class TransactionManager(
     @WorkerThread
     private fun onTransactionsLoaded(
         liveData: MutableLiveData<TransactionsResult>,
-        currency: String?,  // only non-null if we should update all 
transactions cache
+        currency: String?, // only non-null if we should update all 
transactions cache
         result: JSONObject
     ) {
         val transactionsArray = result.getString("transactions")
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 1dc55dc..1ba7e79 100644
--- a/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt
+++ b/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt
@@ -27,6 +27,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo
 import com.fasterxml.jackson.annotation.JsonTypeInfo.As.PROPERTY
 import com.fasterxml.jackson.annotation.JsonTypeInfo.Id.NAME
 import com.fasterxml.jackson.annotation.JsonTypeName
+import kotlinx.serialization.Serializable
 import net.taler.common.Amount
 import net.taler.common.ContractMerchant
 import net.taler.common.ContractProduct
@@ -36,6 +37,8 @@ import net.taler.wallet.cleanExchange
 import net.taler.wallet.transactions.WithdrawalDetails.ManualTransfer
 import net.taler.wallet.transactions.WithdrawalDetails.TalerBankIntegrationApi
 
+data class Transactions(val transactions: List<Transaction>)
+
 @JsonTypeInfo(use = NAME, include = PROPERTY, property = "type")
 @JsonSubTypes(
     Type(value = TransactionWithdrawal::class, name = "withdrawal"),
@@ -72,8 +75,12 @@ sealed class AmountType {
     object Neutral : AmountType()
 }
 
-class TransactionError(private val ec: Int, private val hint: String?) {
-    val text get() = if (hint == null) "$ec" else "$ec - $hint"
+@Serializable
+data class TransactionError(
+    private val ec: Int,
+    private val hint: String?
+) {
+    val text get() = if (hint == null) "$ec" else "$ec $hint"
 }
 
 @JsonTypeName("withdrawal")
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/TosSection.kt 
b/wallet/src/main/java/net/taler/wallet/withdraw/TosSection.kt
index b27de42..b198478 100644
--- a/wallet/src/main/java/net/taler/wallet/withdraw/TosSection.kt
+++ b/wallet/src/main/java/net/taler/wallet/withdraw/TosSection.kt
@@ -17,6 +17,7 @@
 package net.taler.wallet.withdraw
 
 import io.noties.markwon.Markwon
+import kotlinx.serialization.Serializable
 import org.commonmark.node.Code
 import org.commonmark.node.Document
 import org.commonmark.node.Heading
@@ -73,3 +74,9 @@ private fun getNodeText(rootNode: Node): String {
     }
     return text
 }
+
+@Serializable
+data class TosResponse(
+    val tos: String,
+    val currentEtag: String
+)
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt 
b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt
index 6fb9390..1066550 100644
--- a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt
+++ b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt
@@ -19,16 +19,16 @@ package net.taler.wallet.withdraw
 import android.util.Log
 import androidx.annotation.UiThread
 import androidx.lifecycle.MutableLiveData
-import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.module.kotlin.readValue
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.serialization.Serializable
 import net.taler.common.Amount
 import net.taler.wallet.TAG
 import net.taler.wallet.backend.WalletBackendApi
+import net.taler.wallet.backend.WalletErrorInfo
 import net.taler.wallet.exchanges.ExchangeFees
 import net.taler.wallet.exchanges.ExchangeItem
-import net.taler.wallet.getErrorString
 import net.taler.wallet.withdraw.WithdrawStatus.ReceivedDetails
-import org.json.JSONObject
 
 sealed class WithdrawStatus {
     data class Loading(val talerWithdrawUri: String? = null) : WithdrawStatus()
@@ -53,12 +53,14 @@ sealed class WithdrawStatus {
     data class Error(val message: String?) : WithdrawStatus()
 }
 
+@Serializable
 data class WithdrawalDetailsForUri(
     val amount: Amount,
     val defaultExchangeBaseUrl: String?,
     val possibleExchanges: List<ExchangeItem>
 )
 
+@Serializable
 data class WithdrawalDetails(
     val tosAccepted: Boolean,
     val amountRaw: Amount,
@@ -66,8 +68,8 @@ data class WithdrawalDetails(
 )
 
 class WithdrawManager(
-    private val walletBackendApi: WalletBackendApi,
-    private val mapper: ObjectMapper
+    private val api: WalletBackendApi,
+    private val scope: CoroutineScope
 ) {
 
     val withdrawStatus = MutableLiveData<WithdrawStatus>()
@@ -78,22 +80,21 @@ class WithdrawManager(
 
     fun withdrawTestkudos() {
         testWithdrawalInProgress.value = true
-        walletBackendApi.sendRequest("withdrawTestkudos") { _, _ ->
+        api.sendRequest("withdrawTestkudos") { _, _ ->
             testWithdrawalInProgress.postValue(false)
         }
     }
 
-    fun getWithdrawalDetails(uri: String) {
+    fun getWithdrawalDetails(uri: String) = scope.launch {
         withdrawStatus.value = WithdrawStatus.Loading(uri)
-        val args = JSONObject().apply {
-            put("talerWithdrawUri", uri)
-        }
-        walletBackendApi.sendRequest("getWithdrawalDetailsForUri", args) { 
isError, result ->
-            if (isError) {
-                handleError("getWithdrawalDetailsForUri", result)
-                return@sendRequest
+        val response =
+            api.request("getWithdrawalDetailsForUri", 
WithdrawalDetailsForUri.serializer()) {
+                put("talerWithdrawUri", uri)
             }
-            val details: WithdrawalDetailsForUri = 
mapper.readValue(result.toString())
+        response.onError { error ->
+            handleError("getWithdrawalDetailsForUri", error)
+        }
+        response.onSuccess { details ->
             if (details.defaultExchangeBaseUrl == null) {
                 // TODO go to exchange selection screen instead
                 val chosenExchange = 
details.possibleExchanges[0].exchangeBaseUrl
@@ -104,45 +105,51 @@ class WithdrawManager(
         }
     }
 
-    fun getWithdrawalDetails(exchangeBaseUrl: String, amount: Amount, uri: 
String? = null) {
+    fun getWithdrawalDetails(
+        exchangeBaseUrl: String,
+        amount: Amount,
+        uri: String? = null
+    ) = scope.launch {
         withdrawStatus.value = WithdrawStatus.Loading(uri)
-        val args = JSONObject().apply {
-            put("exchangeBaseUrl", exchangeBaseUrl)
-            put("amount", amount.toJSONString())
-        }
-        walletBackendApi.sendRequest("getWithdrawalDetailsForAmount", args) { 
isError, result ->
-            if (isError) {
-                handleError("getWithdrawalDetailsForAmount", result)
-                return@sendRequest
+        val response =
+            api.request("getWithdrawalDetailsForAmount", 
WithdrawalDetails.serializer()) {
+                put("exchangeBaseUrl", exchangeBaseUrl)
+                put("amount", amount.toJSONString())
             }
-            val details: WithdrawalDetails = 
mapper.readValue(result.toString())
-            if (details.tosAccepted)
+        response.onError { error ->
+            handleError("getWithdrawalDetailsForAmount", error)
+        }
+        response.onSuccess { details ->
+            if (details.tosAccepted) {
                 withdrawStatus.value = ReceivedDetails(
                     talerWithdrawUri = uri,
                     exchangeBaseUrl = exchangeBaseUrl,
                     amountRaw = details.amountRaw,
                     amountEffective = details.amountEffective
                 )
-            else getExchangeTos(exchangeBaseUrl, details, uri)
+            } else getExchangeTos(exchangeBaseUrl, details, uri)
         }
     }
 
-    private fun getExchangeTos(exchangeBaseUrl: String, details: 
WithdrawalDetails, uri: String?) {
-        val args = JSONObject().apply {
+    private fun getExchangeTos(
+        exchangeBaseUrl: String,
+        details: WithdrawalDetails,
+        uri: String?
+    ) = scope.launch {
+        val response = api.request("getExchangeTos", TosResponse.serializer()) 
{
             put("exchangeBaseUrl", exchangeBaseUrl)
         }
-        walletBackendApi.sendRequest("getExchangeTos", args) { isError, result 
->
-            if (isError) {
-                handleError("getExchangeTos", result)
-                return@sendRequest
-            }
+        response.onError {
+            handleError("getExchangeTos", it)
+        }
+        response.onSuccess {
             withdrawStatus.value = WithdrawStatus.TosReviewRequired(
                 talerWithdrawUri = uri,
                 exchangeBaseUrl = exchangeBaseUrl,
                 amountRaw = details.amountRaw,
                 amountEffective = details.amountEffective,
-                tosText = result.getString("tos"),
-                tosEtag = result.getString("currentEtag")
+                tosText = it.tos,
+                tosEtag = it.currentEtag
             )
         }
     }
@@ -150,17 +157,14 @@ class WithdrawManager(
     /**
      * Accept the currently displayed terms of service.
      */
-    fun acceptCurrentTermsOfService() {
+    fun acceptCurrentTermsOfService() = scope.launch {
         val s = withdrawStatus.value as WithdrawStatus.TosReviewRequired
-        val args = JSONObject().apply {
+        api.request<Unit>("setExchangeTosAccepted") {
             put("exchangeBaseUrl", s.exchangeBaseUrl)
             put("etag", s.tosEtag)
-        }
-        walletBackendApi.sendRequest("setExchangeTosAccepted", args) { 
isError, result ->
-            if (isError) {
-                handleError("setExchangeTosAccepted", result)
-                return@sendRequest
-            }
+        }.onError {
+            handleError("setExchangeTosAccepted", it)
+        }.onSuccess {
             withdrawStatus.value = ReceivedDetails(
                 talerWithdrawUri = s.talerWithdrawUri,
                 exchangeBaseUrl = s.exchangeBaseUrl,
@@ -171,33 +175,33 @@ class WithdrawManager(
     }
 
     @UiThread
-    fun acceptWithdrawal() {
+    fun acceptWithdrawal() = scope.launch {
         val status = withdrawStatus.value as ReceivedDetails
+        val operation = if (status.talerWithdrawUri == null) {
+            "acceptManualWithdrawal"
+        } else {
+            "acceptBankIntegratedWithdrawal"
+        }
+        withdrawStatus.value = WithdrawStatus.Withdrawing
 
-        val operation = if (status.talerWithdrawUri == null)
-            "acceptManualWithdrawal" else "acceptBankIntegratedWithdrawal"
-        val args = JSONObject().apply {
+        api.request<Unit>(operation) {
             put("exchangeBaseUrl", status.exchangeBaseUrl)
             if (status.talerWithdrawUri == null) {
                 put("amount", status.amountRaw)
             } else {
                 put("talerWithdrawUri", status.talerWithdrawUri)
             }
-        }
-        withdrawStatus.value = WithdrawStatus.Withdrawing
-        walletBackendApi.sendRequest(operation, args) { isError, result ->
-            if (isError) {
-                handleError(operation, result)
-                return@sendRequest
-            }
+        }.onError {
+            handleError(operation, it)
+        }.onSuccess {
             withdrawStatus.value = 
WithdrawStatus.Success(status.amountRaw.currency)
         }
     }
 
     @UiThread
-    private fun handleError(operation: String, result: JSONObject) {
-        Log.e(TAG, "Error $operation ${result.toString(2)}")
-        withdrawStatus.value = WithdrawStatus.Error(getErrorString(result))
+    private fun handleError(operation: String, error: WalletErrorInfo) {
+        Log.e(TAG, "Error $operation $error")
+        withdrawStatus.value = WithdrawStatus.Error(error.userFacingMsg)
     }
 
 }
diff --git a/wallet/src/main/res/drawable/ic_error.xml 
b/wallet/src/main/res/drawable/ic_error.xml
index abbe33e..4f747f1 100644
--- a/wallet/src/main/res/drawable/ic_error.xml
+++ b/wallet/src/main/res/drawable/ic_error.xml
@@ -17,6 +17,7 @@
 <vector xmlns:android="http://schemas.android.com/apk/res/android";
     android:width="24dp"
     android:height="24dp"
+    android:tint="@color/red"
     android:viewportWidth="24.0"
     android:viewportHeight="24.0">
     <path
diff --git a/wallet/src/main/res/drawable/transaction_refresh.xml 
b/wallet/src/main/res/drawable/transaction_refresh.xml
index 219b891..63889d9 100644
--- a/wallet/src/main/res/drawable/transaction_refresh.xml
+++ b/wallet/src/main/res/drawable/transaction_refresh.xml
@@ -17,6 +17,7 @@
 <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
diff --git a/wallet/src/main/res/drawable/transaction_refund.xml 
b/wallet/src/main/res/drawable/transaction_refund.xml
index 6c3d0a7..864add9 100644
--- a/wallet/src/main/res/drawable/transaction_refund.xml
+++ b/wallet/src/main/res/drawable/transaction_refund.xml
@@ -17,6 +17,7 @@
 <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
diff --git a/wallet/src/main/res/drawable/transaction_tip_accepted.xml 
b/wallet/src/main/res/drawable/transaction_tip_accepted.xml
index b945b53..27b1ae4 100644
--- a/wallet/src/main/res/drawable/transaction_tip_accepted.xml
+++ b/wallet/src/main/res/drawable/transaction_tip_accepted.xml
@@ -17,6 +17,7 @@
 <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
diff --git a/wallet/src/main/res/drawable/transaction_withdrawal.xml 
b/wallet/src/main/res/drawable/transaction_withdrawal.xml
index 4fd64f5..edbd4ea 100644
--- a/wallet/src/main/res/drawable/transaction_withdrawal.xml
+++ b/wallet/src/main/res/drawable/transaction_withdrawal.xml
@@ -17,6 +17,7 @@
 <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
diff --git a/wallet/src/main/res/layout/list_item_transaction.xml 
b/wallet/src/main/res/layout/list_item_transaction.xml
index 34712a2..239e656 100644
--- a/wallet/src/main/res/layout/list_item_transaction.xml
+++ b/wallet/src/main/res/layout/list_item_transaction.xml
@@ -33,7 +33,6 @@
         app:layout_constraintBottom_toBottomOf="parent"
         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintTop_toTopOf="parent"
-        app:tint="?android:colorControlNormal"
         tools:ignore="ContentDescription"
         tools:src="@drawable/ic_cash_usd_outline" />
 
diff --git 
a/wallet/src/test/java/net/taler/wallet/backend/WalletResponseTest.kt 
b/wallet/src/test/java/net/taler/wallet/backend/WalletResponseTest.kt
new file mode 100644
index 0000000..698c90a
--- /dev/null
+++ b/wallet/src/test/java/net/taler/wallet/backend/WalletResponseTest.kt
@@ -0,0 +1,90 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2020 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.backend
+
+import com.fasterxml.jackson.databind.DeserializationFeature
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.kotlin.KotlinModule
+import com.fasterxml.jackson.module.kotlin.readValue
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonConfiguration
+import net.taler.common.Amount
+import net.taler.common.AmountMixin
+import net.taler.common.Timestamp
+import net.taler.common.TimestampMixin
+import net.taler.wallet.balances.BalanceResponse
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class WalletResponseTest {
+
+    private val json = Json(
+        JsonConfiguration.Stable.copy(ignoreUnknownKeys = true)
+    )
+
+    private val mapper = ObjectMapper()
+        .registerModule(KotlinModule())
+        .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+        .addMixIn(Amount::class.java, AmountMixin::class.java)
+        .addMixIn(Timestamp::class.java, TimestampMixin::class.java)
+
+    @Test
+    fun testBalanceResponse() {
+        val serializer = 
WalletResponse.Success.serializer(BalanceResponse.serializer())
+        val response = json.parse(
+            serializer, """
+            {
+              "type": "response",
+              "operation": "getBalances",
+              "id": 2,
+              "result": {
+                "balances": [
+                  {
+                    "available": "TESTKUDOS:15.8",
+                    "pendingIncoming": "TESTKUDOS:0",
+                    "pendingOutgoing": "TESTKUDOS:0",
+                    "hasPendingTransactions": false,
+                    "requiresUserInput": false
+                  }
+                ]
+              }
+            }
+        """.trimIndent()
+        )
+        assertEquals(1, response.result.balances.size)
+    }
+
+    @Test
+    fun testWalletErrorInfo() {
+        val infoJson = """
+            {
+                "talerErrorCode":7001,
+                "talerErrorHint":"Error: WALLET_UNEXPECTED_EXCEPTION",
+                "details":{
+                  "httpStatusCode": 401,
+                  "requestUrl": 
"https:\/\/backend.demo.taler.net\/-\/FSF\/orders\/2020.224-02XC8W52BHH3G\/claim",
+                  "requestMethod": "POST"
+                },
+                "message":"unexpected exception: Error: BUG: invariant 
violation (purchase status)"
+            }
+        """.trimIndent()
+        val info = json.parse(WalletErrorInfo.serializer(), infoJson)
+        val infoJackson: WalletErrorInfo = mapper.readValue(infoJson)
+        println(info.userFacingMsg)
+        assertEquals(info.userFacingMsg, infoJackson.userFacingMsg)
+    }
+}

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]