From 2a457a1c46a5da4985035d8bff1db914de161049 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Thu, 22 Aug 2019 23:37:54 +0200 Subject: UX improvements / prototype support for NFC tunneling --- .idea/codeStyles/Project.xml | 109 +++++++++ app/build.gradle | 2 +- app/src/main/AndroidManifest.xml | 7 +- .../java/net/taler/merchantpos/CreatePayment.kt | 65 +----- .../java/net/taler/merchantpos/MainActivity.kt | 251 ++++++++++++++++++++- .../java/net/taler/merchantpos/PaymentSuccess.kt | 29 +++ .../net/taler/merchantpos/PosTerminalViewModel.kt | 9 +- .../java/net/taler/merchantpos/ProcessPayment.kt | 126 +++++------ .../main/res/layout/fragment_create_payment.xml | 7 +- .../main/res/layout/fragment_payment_success.xml | 38 ++++ .../main/res/layout/fragment_process_payment.xml | 3 +- app/src/main/res/navigation/nav_graph.xml | 12 +- build.gradle | 2 +- gradle/wrapper/gradle-wrapper.properties | 4 +- 14 files changed, 518 insertions(+), 146 deletions(-) create mode 100644 app/src/main/java/net/taler/merchantpos/PaymentSuccess.kt create mode 100644 app/src/main/res/layout/fragment_payment_success.xml diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 1bec35e..ce889bd 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -3,6 +3,115 @@ + + + +
+ + + + xmlns:android + + ^$ + + + +
+
+ + + + xmlns:.* + + ^$ + + + BY_NAME + +
+
+ + + + .*:id + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:name + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + name + + ^$ + + + +
+
+ + + + style + + ^$ + + + +
+
+ + + + .* + + ^$ + + + BY_NAME + +
+
+ + + + .* + + http://schemas.android.com/apk/res/android + + + ANDROID_ATTRIBUTE_ORDER + +
+
+ + + + .* + + .* + + + BY_NAME + +
+
+
+
diff --git a/app/build.gradle b/app/build.gradle index 22225ac..0f36545 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,7 +10,7 @@ android { buildToolsVersion "29.0.1" defaultConfig { applicationId "net.taler.merchantpos" - minSdkVersion 28 + minSdkVersion 27 targetSdkVersion 29 versionCode 1 versionName "1.0" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 52f77ef..0920771 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,11 @@ + + + + - - \ No newline at end of file diff --git a/app/src/main/java/net/taler/merchantpos/CreatePayment.kt b/app/src/main/java/net/taler/merchantpos/CreatePayment.kt index 330b9e8..e07802f 100644 --- a/app/src/main/java/net/taler/merchantpos/CreatePayment.kt +++ b/app/src/main/java/net/taler/merchantpos/CreatePayment.kt @@ -8,6 +8,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Button +import android.widget.EditText import androidx.lifecycle.ViewModelProviders import androidx.navigation.fragment.findNavController import com.android.volley.Request @@ -34,19 +35,11 @@ private const val ARG_PARAM2 = "param2" * */ class CreatePayment : Fragment() { - // TODO: Rename and change types of parameters - private var param1: String? = null - private var param2: String? = null - private var listener: OnFragmentInteractionListener? = null private lateinit var queue: RequestQueue private lateinit var model: PosTerminalViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - arguments?.let { - param1 = it.getString(ARG_PARAM1) - param2 = it.getString(ARG_PARAM2) - } model = activity?.run { ViewModelProviders.of(this)[PosTerminalViewModel::class.java] @@ -56,11 +49,14 @@ class CreatePayment : Fragment() { } private fun onRequestPayment() { - val amount = "TESTKUDOS:10.00" + val amountValStr = activity!!.findViewById(R.id.edit_payment_amount).text + val amount = "TESTKUDOS:${amountValStr}" model.activeAmount = amount + model.activeSubject = activity!!.findViewById(R.id.edit_payment_subject).text + var order = JSONObject().also { it.put("amount", amount) - it.put("summary", "hello world") + it.put("summary", model.activeSubject!!) it.put("fulfillment_url", "https://example.com") it.put("instance", "default") } @@ -119,53 +115,4 @@ class CreatePayment : Fragment() { return view } - override fun onAttach(context: Context) { - super.onAttach(context) - if (context is OnFragmentInteractionListener) { - listener = context - } else { - throw RuntimeException(context.toString() + " must implement OnFragmentInteractionListener") - } - } - - override fun onDetach() { - super.onDetach() - listener = null - } - - /** - * This interface must be implemented by activities that contain this - * fragment to allow an interaction in this fragment to be communicated - * to the activity and potentially other fragments contained in that - * activity. - * - * - * See the Android Training lesson [Communicating with Other Fragments] - * (http://developer.android.com/training/basics/fragments/communicating.html) - * for more information. - */ - interface OnFragmentInteractionListener { - // TODO: Update argument type and name - fun onFragmentInteraction(uri: Uri) - } - - companion object { - /** - * Use this factory method to create a new instance of - * this fragment using the provided parameters. - * - * @param param1 Parameter 1. - * @param param2 Parameter 2. - * @return A new instance of fragment CreatePayment. - */ - // TODO: Rename and change types and number of parameters - @JvmStatic - fun newInstance(param1: String, param2: String) = - CreatePayment().apply { - arguments = Bundle().apply { - putString(ARG_PARAM1, param1) - putString(ARG_PARAM2, param2) - } - } - } } diff --git a/app/src/main/java/net/taler/merchantpos/MainActivity.kt b/app/src/main/java/net/taler/merchantpos/MainActivity.kt index 4f67f9c..23d0417 100644 --- a/app/src/main/java/net/taler/merchantpos/MainActivity.kt +++ b/app/src/main/java/net/taler/merchantpos/MainActivity.kt @@ -1,7 +1,10 @@ package net.taler.merchantpos -import android.net.Uri +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.IsoDep import android.os.Bundle +import android.util.Log import androidx.core.view.GravityCompat import android.view.MenuItem import androidx.drawerlayout.widget.DrawerLayout @@ -14,16 +17,243 @@ import androidx.navigation.NavController import androidx.navigation.findNavController import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.setupWithNavController +import net.taler.merchantpos.Utils.Companion.hexStringToByteArray +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.net.URL +import javax.net.ssl.HttpsURLConnection + + +class Utils { + companion object { + private val HEX_CHARS = "0123456789ABCDEF" + fun hexStringToByteArray(data: String): ByteArray { + + val result = ByteArray(data.length / 2) + + for (i in 0 until data.length 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.set(i.shr(1), octet.toByte()) + } + + return result + } + + private val HEX_CHARS_ARRAY = "0123456789ABCDEF".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() + } + } +} + +val TALER_AID = "A0000002471001" + + +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") + } +} + +fun apduSelectFile(): ByteArray { + return hexStringToByteArray("00A4040007A0000002471001") +} + + +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() +} + +fun apduPutTalerData(talerInst: Int, payload: ByteArray): ByteArray { + val realPayload = ByteArrayOutputStream() + realPayload.write(talerInst) + realPayload.write(payload) + return apduPutData(realPayload.toByteArray()) +} + +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() +} + class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, - CreatePayment.OnFragmentInteractionListener, ProcessPayment.OnFragmentInteractionListener { - override fun onFragmentInteraction(uri: Uri) { - TODO("not implemented") //To change body of created functions use File | Settings | File Templates. + NfcAdapter.ReaderCallback { + + companion object { + const val TAG = "taler-merchant" + } + + private lateinit var model: PosTerminalViewModel + private var nfcAdapter: NfcAdapter? = null + + private var currentTag: IsoDep? = null + + override fun onTagDiscovered(tag: Tag?) { + + Log.v(TAG, "tag discovered") + + val isoDep = IsoDep.get(tag) + isoDep.connect() + + currentTag = isoDep + + isoDep.transceive(apduSelectFile()) + + val contractUri: String? = model.activeContractUri + + if (contractUri != null) { + isoDep.transceive(apduPutTalerData(1, contractUri.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() + } + + public override fun onResume() { + super.onResume() + nfcAdapter?.enableReaderMode( + this, this, + NfcAdapter.FLAG_READER_NFC_A or + NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, + null + ) } + public override fun onPause() { + super.onPause() + nfcAdapter?.disableReaderMode(this) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + nfcAdapter = NfcAdapter.getDefaultAdapter(this) + setContentView(R.layout.activity_main) val toolbar: Toolbar = findViewById(R.id.toolbar) setSupportActionBar(toolbar) @@ -35,13 +265,20 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val navController = findNavController(R.id.nav_host_fragment) val appBarConfiguration = - AppBarConfiguration(setOf(R.id.createPayment, R.id.merchantSettings, R.id.merchantHistory), drawerLayout) + AppBarConfiguration( + setOf( + R.id.createPayment, + R.id.merchantSettings, + R.id.merchantHistory + ), drawerLayout + ) findViewById(R.id.toolbar) .setupWithNavController(navController, appBarConfiguration) - val model = ViewModelProviders.of(this)[PosTerminalViewModel::class.java] - model.merchantConfig = MerchantConfig("https://backend.test.taler.net", "default", "sandbox") + model = ViewModelProviders.of(this)[PosTerminalViewModel::class.java] + model.merchantConfig = + MerchantConfig("https://backend.test.taler.net", "default", "sandbox") } diff --git a/app/src/main/java/net/taler/merchantpos/PaymentSuccess.kt b/app/src/main/java/net/taler/merchantpos/PaymentSuccess.kt new file mode 100644 index 0000000..20b6ed1 --- /dev/null +++ b/app/src/main/java/net/taler/merchantpos/PaymentSuccess.kt @@ -0,0 +1,29 @@ +package net.taler.merchantpos + + +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import androidx.navigation.findNavController + +/** + * A simple [Fragment] subclass. + */ +class PaymentSuccess : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.fragment_payment_success, container, false) + view.findViewById