gnunet-svn
[Top][All Lists]
Advanced

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

[taler-cashier-terminal-android] 02/06: Use QR code and NFC to make tale


From: gnunet
Subject: [taler-cashier-terminal-android] 02/06: Use QR code and NFC to make taler:// Uri available
Date: Wed, 19 Feb 2020 21:31:56 +0100

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

torsten-grote pushed a commit to branch master
in repository cashier-terminal-android.

commit 1498c9a6a851f3110f5e64f17690c3f444df8632
Author: Torsten Grote <address@hidden>
AuthorDate: Mon Feb 10 15:25:20 2020 -0300

    Use QR code and NFC to make taler:// Uri available
---
 app/build.gradle                                   |   3 +
 app/src/main/AndroidManifest.xml                   |   1 +
 app/src/main/java/net/taler/cashier/HttpHelper.kt  |  20 +-
 .../main/java/net/taler/cashier/MainViewModel.kt   |  45 ++++-
 app/src/main/java/net/taler/cashier/NfcManager.kt  | 208 +++++++++++++++++++++
 .../main/java/net/taler/cashier/QrCodeManager.kt   |  26 +++
 .../java/net/taler/cashier/TransactionFragment.kt  |  62 +++++-
 app/src/main/java/net/taler/cashier/Utils.kt       |  36 ++++
 .../java/net/taler/cashier/WithdrawFragment.kt     |   2 +-
 app/src/main/res/layout/fragment_transaction.xml   |  59 +++++-
 app/src/main/res/values/strings.xml                |   4 +
 11 files changed, 457 insertions(+), 9 deletions(-)

diff --git a/app/build.gradle b/app/build.gradle
index bffcabc..055f4ba 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -44,6 +44,9 @@ dependencies {
     def lifecycle_version = "2.2.0"
     implementation 
"androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
 
+    // QR codes
+    implementation 'com.google.zxing:core:3.4.0'
+
     implementation "com.squareup.okhttp3:okhttp:3.12.6"
 
     implementation 'androidx.legacy:legacy-support-v4:1.0.0'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 50882ec..7dea3ac 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
 
     <uses-permission android:name="android.permission.INTERNET" />
     <uses-permission android:name="android.permission.GET_ACCOUNTS" />
+    <uses-permission android:name="android.permission.NFC" />
 
     <application
         android:allowBackup="true"
diff --git a/app/src/main/java/net/taler/cashier/HttpHelper.kt 
b/app/src/main/java/net/taler/cashier/HttpHelper.kt
index 3eb589d..fa23bb3 100644
--- a/app/src/main/java/net/taler/cashier/HttpHelper.kt
+++ b/app/src/main/java/net/taler/cashier/HttpHelper.kt
@@ -2,16 +2,20 @@ package net.taler.cashier
 
 import androidx.annotation.WorkerThread
 import okhttp3.Credentials
+import okhttp3.MediaType
 import okhttp3.OkHttpClient
 import okhttp3.Request
+import okhttp3.RequestBody
 import okhttp3.Response
 
 object HttpHelper {
 
+    private const val MIME_TYPE_JSON = "application/json"
+
     @WorkerThread
     fun makeJsonGetRequest(url: String, config: Config): Response {
         val request = Request.Builder()
-            .addHeader("Accept", "application/json")
+            .addHeader("Accept", MIME_TYPE_JSON)
             .url(url)
             .get()
             .build()
@@ -20,6 +24,20 @@ object HttpHelper {
             .execute()
     }
 
+    private val MEDIA_TYPE_JSON = MediaType.parse("$MIME_TYPE_JSON; 
charset=utf-8")
+
+    @WorkerThread
+    fun makeJsonPostRequest(url: String, body: String, config: Config): 
Response {
+        val request = Request.Builder()
+            .addHeader("Accept", MIME_TYPE_JSON)
+            .url(url)
+            .post(RequestBody.create(MEDIA_TYPE_JSON, body))
+            .build()
+        return getHttpClient(config.username, config.password)
+            .newCall(request)
+            .execute()
+    }
+
     private fun getHttpClient(username: String, password: String) =
         OkHttpClient.Builder().authenticator { _, response ->
             val credential = Credentials.basic(username, password)
diff --git a/app/src/main/java/net/taler/cashier/MainViewModel.kt 
b/app/src/main/java/net/taler/cashier/MainViewModel.kt
index a6accfe..bb94e45 100644
--- a/app/src/main/java/net/taler/cashier/MainViewModel.kt
+++ b/app/src/main/java/net/taler/cashier/MainViewModel.kt
@@ -2,6 +2,7 @@ package net.taler.cashier
 
 import android.annotation.SuppressLint
 import android.app.Application
+import android.graphics.Bitmap
 import android.util.Log
 import androidx.annotation.UiThread
 import androidx.annotation.WorkerThread
@@ -17,6 +18,7 @@ import androidx.security.crypto.MasterKeys.AES256_GCM_SPEC
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 import net.taler.cashier.HttpHelper.makeJsonGetRequest
+import net.taler.cashier.HttpHelper.makeJsonPostRequest
 import org.json.JSONObject
 
 private val TAG = MainViewModel::class.java.simpleName
@@ -44,10 +46,17 @@ class MainViewModel(private val app: Application) : 
AndroidViewModel(app) {
     private val mConfigResult = MutableLiveData<ConfigResult>()
     val configResult: LiveData<ConfigResult> = mConfigResult
 
+    // TODO get real currency
+    private val currency = "KUDOS"
+
     private val mBalance = MutableLiveData<String>()
     val balance: LiveData<String> = mBalance
 
-    var currentWithdrawAmount: Int = 0
+    private val mWithdrawAmount = MutableLiveData<String>()
+    val withdrawAmount: LiveData<String> = mWithdrawAmount
+
+    private val mWithdrawResult = MutableLiveData<WithdrawResult>()
+    val withdrawResult: LiveData<WithdrawResult> = mWithdrawResult
 
     fun hasConfig() = config.bankUrl.isNotEmpty()
             && config.username.isNotEmpty()
@@ -119,7 +128,7 @@ class MainViewModel(private val app: Application) : 
AndroidViewModel(app) {
     }
 
     fun lock() {
-        saveConfig(config.copy(password=""))
+        saveConfig(config.copy(password = ""))
     }
 
     @UiThread
@@ -130,6 +139,32 @@ class MainViewModel(private val app: Application) : 
AndroidViewModel(app) {
         return amount <= balanceDouble
     }
 
+    @UiThread
+    fun withdraw(amount: Int) {
+        check(amount > 0) { "Withdraw amount was <= 0" }
+        mWithdrawResult.value = null
+        mWithdrawAmount.value = "$amount $currency"
+        viewModelScope.launch(Dispatchers.IO) {
+            val url = "${config.bankUrl}/taler-bank-api/testing/withdraw-uri"
+            val body = JSONObject(mapOf("amount" to 
"${currency}:${amount}")).toString()
+            val response = try {
+                makeJsonPostRequest(url, body, config)
+            } catch (e: Exception) {
+                Log.e(TAG, "Error fetching $url", e)
+                val errorStr = app.getString(R.string.withdraw_error_fetch)
+                return@launch 
mWithdrawResult.postValue(WithdrawResult.Error(errorStr))
+            }
+            if (response.code() == 200 && response.body() != null) {
+                val json = JSONObject(response.body()!!.string())
+                Log.e("TEST", json.toString(4))
+            } else {
+                Log.e(TAG, "Error fetching $url - Response: 
${response.code()}")
+                val errorStr = app.getString(R.string.withdraw_error_fetch)
+                mWithdrawResult.postValue(WithdrawResult.Error(errorStr))
+            }
+        }
+    }
+
 }
 
 data class Config(
@@ -139,3 +174,9 @@ data class Config(
 )
 
 class ConfigResult(val success: Boolean, val authError: Boolean = false)
+
+sealed class WithdrawResult {
+    object InsufficientBalance : WithdrawResult()
+    class Error(val msg: String) : WithdrawResult()
+    class Success(val talerUri: String, val qrCode: Bitmap) : WithdrawResult()
+}
diff --git a/app/src/main/java/net/taler/cashier/NfcManager.kt 
b/app/src/main/java/net/taler/cashier/NfcManager.kt
new file mode 100644
index 0000000..5473eb0
--- /dev/null
+++ b/app/src/main/java/net/taler/cashier/NfcManager.kt
@@ -0,0 +1,208 @@
+package net.taler.cashier
+
+import android.app.Activity
+import android.content.Context
+import android.nfc.NfcAdapter
+import android.nfc.NfcAdapter.FLAG_READER_NFC_A
+import android.nfc.NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
+import android.nfc.Tag
+import android.nfc.tech.IsoDep
+import android.util.Log
+import net.taler.cashier.Utils.hexStringToByteArray
+import org.json.JSONObject
+import java.io.ByteArrayOutputStream
+import java.net.URL
+import javax.net.ssl.HttpsURLConnection
+
+class NfcManager : NfcAdapter.ReaderCallback {
+
+    companion object {
+        const val TAG = "taler-merchant"
+
+        /**
+         * Enables NFC reader mode. Don't forget to call [stop] afterwards.
+         */
+        fun start(activity: Activity, nfcManager: NfcManager) {
+            getNfcAdapter(activity)?.enableReaderMode(activity, nfcManager, 
nfcManager.flags, null)
+        }
+
+        /**
+         * Disables NFC reader mode. Call after [start].
+         */
+        fun stop(activity: Activity) {
+            getNfcAdapter(activity)?.disableReaderMode(activity)
+        }
+
+        private fun getNfcAdapter(context: Context): NfcAdapter? {
+            return NfcAdapter.getDefaultAdapter(context)
+        }
+    }
+
+    private val TALER_AID = "A0000002471001"
+    private val flags = FLAG_READER_NFC_A or FLAG_READER_SKIP_NDEF_CHECK
+
+    private var tagString: String? = null
+    private var currentTag: IsoDep? = null
+
+    fun setTagString(tagString: String) {
+        this.tagString = tagString
+    }
+
+    override fun onTagDiscovered(tag: Tag?) {
+
+        Log.v(TAG, "tag discovered")
+
+        val isoDep = IsoDep.get(tag)
+        isoDep.connect()
+
+        currentTag = isoDep
+
+        isoDep.transceive(apduSelectFile())
+
+        val tagString: String? = tagString
+        if (tagString != null) {
+            isoDep.transceive(apduPutTalerData(1, tagString.toByteArray()))
+        }
+
+        // FIXME: use better pattern for sleeps in between requests
+        // -> start with fast polling, poll more slowly if no requests are 
coming
+
+        while (true) {
+            try {
+                val reqFrame = isoDep.transceive(apduGetData())
+                if (reqFrame.size < 2) {
+                    Log.v(TAG, "request frame too small")
+                    break
+                }
+                val req = ByteArray(reqFrame.size - 2)
+                if (req.isEmpty()) {
+                    continue
+                }
+                reqFrame.copyInto(req, 0, 0, reqFrame.size - 2)
+                val jsonReq = JSONObject(req.toString(Charsets.UTF_8))
+                val reqId = jsonReq.getInt("id")
+                Log.v(TAG, "got request $jsonReq")
+                val jsonInnerReq = jsonReq.getJSONObject("request")
+                val method = jsonInnerReq.getString("method")
+                val urlStr = jsonInnerReq.getString("url")
+                Log.v(TAG, "url '$urlStr'")
+                Log.v(TAG, "method '$method'")
+                val url = URL(urlStr)
+                val conn: HttpsURLConnection = url.openConnection() as 
HttpsURLConnection
+                conn.setRequestProperty("Accept", "application/json")
+                conn.connectTimeout = 5000
+                conn.doInput = true
+                when (method) {
+                    "get" -> {
+                        conn.requestMethod = "GET"
+                    }
+                    "postJson" -> {
+                        conn.requestMethod = "POST"
+                        conn.doOutput = true
+                        conn.setRequestProperty("Content-Type", 
"application/json; utf-8")
+                        val body = jsonInnerReq.getString("body")
+                        
conn.outputStream.write(body.toByteArray(Charsets.UTF_8))
+                    }
+                    else -> {
+                        throw Exception("method not supported")
+                    }
+                }
+                Log.v(TAG, "connecting")
+                conn.connect()
+                Log.v(TAG, "connected")
+
+                val statusCode = conn.responseCode
+                val tunnelResp = JSONObject()
+                tunnelResp.put("id", reqId)
+                tunnelResp.put("status", conn.responseCode)
+
+                if (statusCode == 200) {
+                    val stream = conn.inputStream
+                    val httpResp = stream.buffered().readBytes()
+                    tunnelResp.put("responseJson", 
JSONObject(httpResp.toString(Charsets.UTF_8)))
+                }
+
+                Log.v(TAG, "sending: $tunnelResp")
+
+                isoDep.transceive(apduPutTalerData(2, 
tunnelResp.toString().toByteArray()))
+            } catch (e: Exception) {
+                Log.v(TAG, "exception during NFC loop: $e")
+                break
+            }
+        }
+
+        isoDep.close()
+    }
+
+    private fun writeApduLength(stream: ByteArrayOutputStream, size: Int) {
+        when {
+            size == 0 -> {
+                // No size field needed!
+            }
+            size <= 255 -> // One byte size field
+                stream.write(size)
+            size <= 65535 -> {
+                stream.write(0)
+                // FIXME: is this supposed to be little or big endian?
+                stream.write(size and 0xFF)
+                stream.write((size ushr 8) and 0xFF)
+            }
+            else -> throw Error("payload too big")
+        }
+    }
+
+    private fun apduSelectFile(): ByteArray {
+        return hexStringToByteArray("00A4040007A0000002471001")
+    }
+
+    private fun apduPutData(payload: ByteArray): ByteArray {
+        val stream = ByteArrayOutputStream()
+
+        // Class
+        stream.write(0x00)
+
+        // Instruction 0xDA = put data
+        stream.write(0xDA)
+
+        // Instruction parameters
+        // (proprietary encoding)
+        stream.write(0x01)
+        stream.write(0x00)
+
+        writeApduLength(stream, payload.size)
+
+        stream.write(payload)
+
+        return stream.toByteArray()
+    }
+
+    private fun apduPutTalerData(talerInst: Int, payload: ByteArray): 
ByteArray {
+        val realPayload = ByteArrayOutputStream()
+        realPayload.write(talerInst)
+        realPayload.write(payload)
+        return apduPutData(realPayload.toByteArray())
+    }
+
+    private fun apduGetData(): ByteArray {
+        val stream = ByteArrayOutputStream()
+
+        // Class
+        stream.write(0x00)
+
+        // Instruction 0xCA = get data
+        stream.write(0xCA)
+
+        // Instruction parameters
+        // (proprietary encoding)
+        stream.write(0x01)
+        stream.write(0x00)
+
+        // Max expected response size, two
+        // zero bytes denotes 65536
+        stream.write(0x0)
+        stream.write(0x0)
+
+        return stream.toByteArray()
+    }
+
+}
diff --git a/app/src/main/java/net/taler/cashier/QrCodeManager.kt 
b/app/src/main/java/net/taler/cashier/QrCodeManager.kt
new file mode 100644
index 0000000..61060a2
--- /dev/null
+++ b/app/src/main/java/net/taler/cashier/QrCodeManager.kt
@@ -0,0 +1,26 @@
+package net.taler.cashier
+
+import android.graphics.Bitmap
+import android.graphics.Bitmap.Config.RGB_565
+import android.graphics.Color.BLACK
+import android.graphics.Color.WHITE
+import com.google.zxing.BarcodeFormat.QR_CODE
+import com.google.zxing.qrcode.QRCodeWriter
+
+object QrCodeManager {
+
+    fun makeQrCode(text: String, size: Int = 256): Bitmap {
+        val qrCodeWriter = QRCodeWriter()
+        val bitMatrix = qrCodeWriter.encode(text, QR_CODE, size, size)
+        val height = bitMatrix.height
+        val width = bitMatrix.width
+        val bmp = Bitmap.createBitmap(width, height, RGB_565)
+        for (x in 0 until width) {
+            for (y in 0 until height) {
+                bmp.setPixel(x, y, if (bitMatrix.get(x, y)) BLACK else WHITE)
+            }
+        }
+        return bmp
+    }
+
+}
diff --git a/app/src/main/java/net/taler/cashier/TransactionFragment.kt 
b/app/src/main/java/net/taler/cashier/TransactionFragment.kt
index 1c9c0db..13e6005 100644
--- a/app/src/main/java/net/taler/cashier/TransactionFragment.kt
+++ b/app/src/main/java/net/taler/cashier/TransactionFragment.kt
@@ -3,14 +3,23 @@ package net.taler.cashier
 import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.View
+import android.view.View.INVISIBLE
+import android.view.View.VISIBLE
 import android.view.ViewGroup
+import androidx.core.content.ContextCompat.getColor
 import androidx.fragment.app.Fragment
 import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Observer
+import androidx.navigation.fragment.findNavController
 import kotlinx.android.synthetic.main.fragment_transaction.*
+import net.taler.cashier.WithdrawResult.Error
+import net.taler.cashier.WithdrawResult.InsufficientBalance
+import net.taler.cashier.WithdrawResult.Success
 
 class TransactionFragment : Fragment() {
 
     private val viewModel: MainViewModel by activityViewModels()
+    private val nfcManager = NfcManager()
 
     override fun onCreateView(
         inflater: LayoutInflater, container: ViewGroup?,
@@ -20,7 +29,58 @@ class TransactionFragment : Fragment() {
     }
 
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        test.text = viewModel.currentWithdrawAmount.toString()
+        viewModel.withdrawAmount.observe(viewLifecycleOwner, Observer { amount 
->
+            amountView.text = amount
+        })
+        viewModel.withdrawResult.observe(viewLifecycleOwner, Observer { result 
->
+            if (result != null) {
+                progressBar.animate()
+                    .alpha(0f)
+                    .withEndAction { progressBar.visibility = INVISIBLE }
+                    .setDuration(750)
+                    .start()
+            }
+            when (result) {
+                is InsufficientBalance -> {
+                    val c = getColor(requireContext(), 
R.color.design_default_color_error)
+                    introView.setTextColor(c)
+                    introView.text = 
getString(R.string.withdraw_error_insufficient_balance)
+                }
+                is Error -> {
+                    val c = getColor(requireContext(), 
R.color.design_default_color_error)
+                    introView.setTextColor(c)
+                    introView.text = result.msg
+                }
+                is Success -> {
+                    // start NFC
+                    nfcManager.setTagString(result.talerUri)
+                    NfcManager.start(requireActivity(), nfcManager)
+                    // show QR code
+                    qrCodeView.alpha = 0f
+                    qrCodeView.animate()
+                        .alpha(1f)
+                        .withStartAction {
+                            qrCodeView.visibility = VISIBLE
+                            qrCodeView.setImageBitmap(result.qrCode)
+                        }
+                        .setDuration(750)
+                        .start()
+                }
+            }
+        })
+        cancelButton.setOnClickListener { findNavController().popBackStack() }
+    }
+
+    override fun onStart() {
+        super.onStart()
+        if (viewModel.withdrawResult.value is Success) {
+            NfcManager.start(requireActivity(), nfcManager)
+        }
+    }
+
+    override fun onStop() {
+        super.onStop()
+        NfcManager.stop(requireActivity())
     }
 
 }
diff --git a/app/src/main/java/net/taler/cashier/Utils.kt 
b/app/src/main/java/net/taler/cashier/Utils.kt
new file mode 100644
index 0000000..edcdad2
--- /dev/null
+++ b/app/src/main/java/net/taler/cashier/Utils.kt
@@ -0,0 +1,36 @@
+package net.taler.cashier
+
+object Utils {
+
+    private const val HEX_CHARS = "0123456789ABCDEF"
+
+    fun hexStringToByteArray(data: String): ByteArray {
+        val result = ByteArray(data.length / 2)
+
+        for (i in data.indices step 2) {
+            val firstIndex = HEX_CHARS.indexOf(data[i])
+            val secondIndex = HEX_CHARS.indexOf(data[i + 1])
+
+            val octet = firstIndex.shl(4).or(secondIndex)
+            result[i.shr(1)] = octet.toByte()
+        }
+        return result
+    }
+
+
+    private val HEX_CHARS_ARRAY = HEX_CHARS.toCharArray()
+
+    fun toHex(byteArray: ByteArray): String {
+        val result = StringBuffer()
+
+        byteArray.forEach {
+            val octet = it.toInt()
+            val firstIndex = (octet and 0xF0).ushr(4)
+            val secondIndex = octet and 0x0F
+            result.append(HEX_CHARS_ARRAY[firstIndex])
+            result.append(HEX_CHARS_ARRAY[secondIndex])
+        }
+        return result.toString()
+    }
+
+}
diff --git a/app/src/main/java/net/taler/cashier/WithdrawFragment.kt 
b/app/src/main/java/net/taler/cashier/WithdrawFragment.kt
index 08db4af..75fd99f 100644
--- a/app/src/main/java/net/taler/cashier/WithdrawFragment.kt
+++ b/app/src/main/java/net/taler/cashier/WithdrawFragment.kt
@@ -58,7 +58,7 @@ class WithdrawFragment : Fragment() {
             amountView.error = 
getString(R.string.withdraw_error_insufficient_balance)
         } else {
             amountView.error = null
-            viewModel.currentWithdrawAmount = amount
+            viewModel.withdraw(amount)
             
WithdrawFragmentDirections.actionWithdrawFragmentToTransactionFragment().let {
                 findNavController().navigate(it)
             }
diff --git a/app/src/main/res/layout/fragment_transaction.xml 
b/app/src/main/res/layout/fragment_transaction.xml
index 533593d..3804595 100644
--- a/app/src/main/res/layout/fragment_transaction.xml
+++ b/app/src/main/res/layout/fragment_transaction.xml
@@ -2,18 +2,69 @@
 <androidx.constraintlayout.widget.ConstraintLayout 
xmlns:android="http://schemas.android.com/apk/res/android";
     xmlns:app="http://schemas.android.com/apk/res-auto";
     xmlns:tools="http://schemas.android.com/tools";
-    android:id="@+id/frameLayout"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
     tools:context=".TransactionFragment">
 
     <TextView
-        android:id="@+id/test"
+        android:id="@+id/introView"
         android:layout_width="0dp"
-        android:layout_height="0dp"
-        app:layout_constraintBottom_toBottomOf="parent"
+        android:layout_height="wrap_content"
+        android:layout_margin="32dp"
+        android:gravity="center_horizontal"
+        android:text="@string/transaction_intro"
+        android:textAppearance="@style/TextAppearance.AppCompat.Medium"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintTop_toTopOf="parent" />
 
+    <TextView
+        android:id="@+id/amountView"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_margin="32dp"
+        android:gravity="center_horizontal"
+        android:textAppearance="@style/TextAppearance.AppCompat.Headline"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/introView"
+        tools:text="50 KUDOS" />
+
+    <ImageView
+        android:id="@+id/qrCodeView"
+        android:layout_width="256dp"
+        android:layout_height="256dp"
+        android:layout_margin="32dp"
+        android:keepScreenOn="true"
+        android:visibility="invisible"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/amountView"
+        tools:ignore="ContentDescription"
+        tools:src="@tools:sample/avatars"
+        tools:visibility="visible" />
+
+    <ProgressBar
+        android:id="@+id/progressBar"
+        style="?android:attr/progressBarStyleLarge"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintBottom_toBottomOf="@+id/qrCodeView"
+        app:layout_constraintEnd_toEndOf="@+id/qrCodeView"
+        app:layout_constraintStart_toStartOf="@+id/qrCodeView"
+        app:layout_constraintTop_toTopOf="@+id/qrCodeView" />
+
+    <Button
+        android:id="@+id/cancelButton"
+        style="@style/Widget.AppCompat.Button.Colored"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_margin="32dp"
+        android:text="@string/transaction_cancel"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/qrCodeView"
+        app:layout_constraintVertical_bias="1.0" />
+
 </androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/values/strings.xml 
b/app/src/main/res/values/strings.xml
index f122111..db80499 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -18,5 +18,9 @@
     <string name="withdraw_into">How much to cash in?</string>
     <string name="withdraw_error_zero">Enter positive amount</string>
     <string name="withdraw_error_insufficient_balance">Insufficient 
balance</string>
+    <string name="withdraw_error_fetch">Error communicating with bank</string>
+
+    <string name="transaction_intro">Scan code or use NFC\nwith the Taler 
wallet app\nto get</string>
+    <string name="transaction_cancel">Cancel</string>
 
 </resources>

-- 
To stop receiving notification emails like this one, please contact
address@hidden.



reply via email to

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