diff options
author | Florian Dold <florian.dold@gmail.com> | 2019-08-22 23:37:22 +0200 |
---|---|---|
committer | Florian Dold <florian.dold@gmail.com> | 2019-08-22 23:37:22 +0200 |
commit | b9fd051a1bf453e923ddbbf86cf8602d154278e1 (patch) | |
tree | 2dfa438cd76fc4fc41079359a359f2325ac129dd /app/src/main/java | |
parent | defff18c7c8dbfc87fbc282da005ff25cda1159f (diff) | |
download | wallet-android-b9fd051a1bf453e923ddbbf86cf8602d154278e1.tar.gz wallet-android-b9fd051a1bf453e923ddbbf86cf8602d154278e1.tar.bz2 wallet-android-b9fd051a1bf453e923ddbbf86cf8602d154278e1.zip |
UX improvements / prototype support for NFC tunneling
Diffstat (limited to 'app/src/main/java')
5 files changed, 340 insertions, 10 deletions
diff --git a/app/src/main/java/net/taler/wallet/AlreadyPaid.kt b/app/src/main/java/net/taler/wallet/AlreadyPaid.kt new file mode 100644 index 0000000..40903f9 --- /dev/null +++ b/app/src/main/java/net/taler/wallet/AlreadyPaid.kt @@ -0,0 +1,30 @@ +package net.taler.wallet + + +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 AlreadyPaid : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + // Inflate the layout for this fragment + val view = inflater.inflate(R.layout.fragment_already_paid, container, false) + view.findViewById<Button>(R.id.button_success_back).setOnClickListener { + activity!!.findNavController(R.id.nav_host_fragment).navigateUp() + } + return view + } + + +} diff --git a/app/src/main/java/net/taler/wallet/HostCardEmulatorService.kt b/app/src/main/java/net/taler/wallet/HostCardEmulatorService.kt new file mode 100644 index 0000000..cdcf492 --- /dev/null +++ b/app/src/main/java/net/taler/wallet/HostCardEmulatorService.kt @@ -0,0 +1,204 @@ +package net.taler.wallet + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.nfc.cardemulation.HostApduService +import android.os.Bundle +import android.util.Log +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.* +import java.util.concurrent.ConcurrentLinkedDeque +import java.util.concurrent.ConcurrentLinkedQueue + +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() + } + } +} + + +fun makeApduSuccessResponse(payload: ByteArray): ByteArray { + val stream = ByteArrayOutputStream() + stream.write(payload) + stream.write(0x90) + stream.write(0x00) + return stream.toByteArray() +} + + +fun makeApduFailureResponse(): ByteArray { + val stream = ByteArrayOutputStream() + stream.write(0x6F) + stream.write(0x00) + return stream.toByteArray() +} + + +fun readApduBodySize(stream: ByteArrayInputStream): Int { + val b0 = stream.read() + if (b0 == -1) { + return 0; + } + if (b0 != 0) { + return b0 + } + val b1 = stream.read() + val b2 = stream.read() + + return (b1 shl 8) and b2 +} + + +class HostCardEmulatorService: HostApduService() { + + val queuedRequests: ConcurrentLinkedDeque<String> = ConcurrentLinkedDeque() + + override fun onCreate() { + IntentFilter(HTTP_TUNNEL_REQUEST).also { filter -> + registerReceiver(object : BroadcastReceiver() { + override fun onReceive(p0: Context?, p1: Intent?) { + queuedRequests.addLast(p1!!.getStringExtra("tunnelMessage")) + } + }, filter) + } + } + + override fun onDeactivated(reason: Int) { + Log.d(TAG, "Deactivated: " + reason) + Intent().also { intent -> + intent.action = MERCHANT_NFC_DISCONNECTED + sendBroadcast(intent) + } + } + + override fun processCommandApdu(commandApdu: ByteArray?, + extras: Bundle?): ByteArray { + + //Log.d(TAG, "Processing command APDU") + + if (commandApdu == null) { + Log.d(TAG, "APDU is null") + return makeApduFailureResponse() + } + + val stream = ByteArrayInputStream(commandApdu) + + val command = stream.read() + + if (command != 0) { + Log.d(TAG, "APDU has invalid command") + return makeApduFailureResponse() + } + + val instruction = stream.read() + + val instructionStr = "%02x".format(instruction) + + //Log.v(TAG, "Processing instruction $instructionStr") + + val p1 = stream.read() + val p2 = stream.read() + + //Log.v(TAG, "instruction paramaters $p1 $p2") + + if (instruction == SELECT_INS) { + // FIXME: validate body! + return makeApduSuccessResponse(ByteArray(0)) + } + + if (instruction == GET_INS) { + val req = queuedRequests.poll() + return if (req != null) { + Log.v(TAG,"sending tunnel request") + makeApduSuccessResponse(req.toByteArray(Charsets.UTF_8)) + } else { + makeApduSuccessResponse(ByteArray(0)) + } + } + + if (instruction == PUT_INS) { + val bodySize = readApduBodySize(stream) + + + val talerInstr = stream.read() + val bodyBytes = stream.readBytes() + + + when (talerInstr.toInt()) { + 1 -> { + val url = String(bodyBytes, Charsets.UTF_8) + + Intent().also { intent -> + intent.action = TRIGGER_PAYMENT_ACTION + intent.putExtra("contractUrl", url) + sendBroadcast(intent) + } + } + 2 -> { + Log.v(TAG, "got http response: ${bodyBytes.toString(Charsets.UTF_8)}") + + Intent().also { intent -> + intent.action = HTTP_TUNNEL_RESPONSE + intent.putExtra("response", bodyBytes.toString(Charsets.UTF_8)) + sendBroadcast(intent) + } + } + else -> { + Log.v(TAG, "taler instruction $talerInstr unknown") + } + } + + return makeApduSuccessResponse(ByteArray(0)) + } + + return makeApduFailureResponse() + } + + companion object { + val TAG = "taler-wallet-hce" + val AID = "A0000002471001" + val SELECT_INS = 0xA4 + val PUT_INS = 0xDA + val GET_INS = 0xCA + + val TRIGGER_PAYMENT_ACTION = "net.taler.TRIGGER_PAYMENT_ACTION" + + val MERCHANT_NFC_CONNECTED = "net.taler.MERCHANT_NFC_CONNECTED" + val MERCHANT_NFC_DISCONNECTED = "net.taler.MERCHANT_NFC_DISCONNECTED" + + val HTTP_TUNNEL_RESPONSE = "net.taler.HTTP_TUNNEL_RESPONSE" + val HTTP_TUNNEL_REQUEST = "net.taler.HTTP_TUNNEL_REQUEST" + } +}
\ No newline at end of file diff --git a/app/src/main/java/net/taler/wallet/MainActivity.kt b/app/src/main/java/net/taler/wallet/MainActivity.kt index e539cfd..44cd8a6 100644 --- a/app/src/main/java/net/taler/wallet/MainActivity.kt +++ b/app/src/main/java/net/taler/wallet/MainActivity.kt @@ -1,7 +1,10 @@ package net.taler.wallet +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.os.Bundle import android.util.Log import android.view.Menu @@ -11,7 +14,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar import androidx.core.view.GravityCompat import androidx.drawerlayout.widget.DrawerLayout -import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders import androidx.navigation.findNavController import androidx.navigation.ui.AppBarConfiguration @@ -22,6 +24,8 @@ import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentResult import me.zhanghai.android.materialprogressbar.MaterialProgressBar + + class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener { lateinit var model: WalletViewModel @@ -54,6 +58,50 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte model.init() model.getBalances() + + val triggerPaymentFilter = IntentFilter(HostCardEmulatorService.TRIGGER_PAYMENT_ACTION) + registerReceiver(object : BroadcastReceiver() { + override fun onReceive(p0: Context?, p1: Intent?) { + + if (model.payStatus.value !is PayStatus.None) { + return + } + + val url = p1!!.extras!!.get("contractUrl") as String + + findNavController(R.id.nav_host_fragment).navigate(R.id.action_showBalance_to_promptPayment) + model.preparePay(url) + + } + }, triggerPaymentFilter) + + val nfcConnectedFilter = IntentFilter(HostCardEmulatorService.MERCHANT_NFC_CONNECTED) + registerReceiver(object : BroadcastReceiver() { + override fun onReceive(p0: Context?, p1: Intent?) { + Log.v(TAG, "got MERCHANT_NFC_CONNECTED") + //model.startTunnel() + } + }, nfcConnectedFilter) + + val nfcDisconnectedFilter = IntentFilter(HostCardEmulatorService.MERCHANT_NFC_DISCONNECTED) + registerReceiver(object : BroadcastReceiver() { + override fun onReceive(p0: Context?, p1: Intent?) { + Log.v(TAG, "got MERCHANT_NFC_DISCONNECTED") + //model.stopTunnel() + } + }, nfcDisconnectedFilter) + + + IntentFilter(HostCardEmulatorService.HTTP_TUNNEL_RESPONSE).also { filter -> + registerReceiver(object : BroadcastReceiver() { + override fun onReceive(p0: Context?, p1: Intent?) { + Log.v("taler-tunnel", "got HTTP_TUNNEL_RESPONSE") + model.tunnelResponse(p1!!.getStringExtra("response")) + } + }, filter) + } + + //model.startTunnel() } override fun onBackPressed() { diff --git a/app/src/main/java/net/taler/wallet/PromptPayment.kt b/app/src/main/java/net/taler/wallet/PromptPayment.kt index 9486814..07d3dd2 100644 --- a/app/src/main/java/net/taler/wallet/PromptPayment.kt +++ b/app/src/main/java/net/taler/wallet/PromptPayment.kt @@ -32,7 +32,8 @@ class PromptPayment : Fragment() { var fragmentView: View? = null - fun triggerLoading(loading: Boolean) { + fun triggerLoading() { + val loading = model.payStatus.value == null || (model.payStatus.value is PayStatus.Loading) val myActivity = activity!! val progressBar = myActivity.findViewById<MaterialProgressBar>(R.id.progress_bar) if (loading) { @@ -49,13 +50,13 @@ class PromptPayment : Fragment() { ViewModelProviders.of(this)[WalletViewModel::class.java] } ?: throw Exception("Invalid Activity") - triggerLoading(true) + triggerLoading() } override fun onResume() { super.onResume() Log.v("taler-wallet", "called onResume on PromptPayment") - triggerLoading(model.payStatus.value == null || model.payStatus.value is PayStatus.Loading) + triggerLoading() } fun fillOrderInfo(view: View, contractTerms: ContractTerms, totalFees: Amount?) { @@ -90,24 +91,31 @@ class PromptPayment : Fragment() { confirmPaymentButton.setOnClickListener { model.confirmPay(payStatus.proposalId) - triggerLoading(true) confirmPaymentButton.isEnabled = false } - triggerLoading(false) } is PayStatus.InsufficientBalance -> { fillOrderInfo(view, payStatus.contractTerms, null) promptPaymentDetails.visibility = View.VISIBLE balanceInsufficientWarning.visibility = View.VISIBLE confirmPaymentButton.isEnabled = false - triggerLoading(false) } is PayStatus.Success -> { - triggerLoading(false) + model.payStatus.value = PayStatus.None() activity!!.findNavController(R.id.nav_host_fragment).navigate(R.id.action_promptPayment_to_paymentSuccessful) } + is PayStatus.AlreadyPaid -> { + activity!!.findNavController(R.id.nav_host_fragment).navigate(R.id.action_promptPayment_to_alreadyPaid) + model.payStatus.value = PayStatus.None() + } + is PayStatus.None -> { + // No payment active. + } + is PayStatus.Loading -> { + // Wait until loaded ... + } else -> { - val bar = Snackbar.make(view , "Unexpected result", Snackbar.LENGTH_SHORT) + val bar = Snackbar.make(view , "Bug: Unexpected result", Snackbar.LENGTH_SHORT) bar.show() } } @@ -133,9 +141,10 @@ class PromptPayment : Fragment() { activity!!.findNavController(R.id.nav_host_fragment).navigateUp() } - triggerLoading(true) + triggerLoading() model.payStatus.observe(this, Observer { + triggerLoading() showPayStatus(view, it) }) return view diff --git a/app/src/main/java/net/taler/wallet/WalletViewModel.kt b/app/src/main/java/net/taler/wallet/WalletViewModel.kt index 7c82f81..f644c8d 100644 --- a/app/src/main/java/net/taler/wallet/WalletViewModel.kt +++ b/app/src/main/java/net/taler/wallet/WalletViewModel.kt @@ -3,6 +3,7 @@ package net.taler.wallet import akono.AkonoJni import akono.ModuleResult import android.app.Application +import android.content.Intent import android.content.res.AssetManager import android.util.Log import androidx.lifecycle.AndroidViewModel @@ -139,6 +140,7 @@ data class WalletBalances(val initialized: Boolean, val byCurrency: List<Amount> data class ContractTerms(val summary: String, val amount: Amount) open class PayStatus { + class None : PayStatus() class Loading : PayStatus() data class Prepared(val contractTerms: ContractTerms, val proposalId: Int, val totalFees: Amount) : PayStatus() data class InsufficientBalance(val contractTerms: ContractTerms) : PayStatus() @@ -165,6 +167,7 @@ class WalletViewModel(val app: Application) : AndroidViewModel(app) { init { isBalanceLoading.value = false balances.value = WalletBalances(false, listOf()) + payStatus.value = PayStatus.None() } fun init() { @@ -185,6 +188,14 @@ class WalletViewModel(val app: Application) : AndroidViewModel(app) { "notification" -> { getBalances() } + "tunnelHttp" -> { + Log.v(TAG, "got http tunnel request!") + Intent().also { intent -> + intent.action = HostCardEmulatorService.HTTP_TUNNEL_REQUEST + intent.putExtra("tunnelMessage", messageStr) + app.sendBroadcast(intent) + } + } "response" -> { val operation = message.getString("operation") Log.v(TAG, "got response for operation $operation") @@ -251,6 +262,7 @@ class WalletViewModel(val app: Application) : AndroidViewModel(app) { sendInitMessage() + this.initialized = true } @@ -301,6 +313,8 @@ class WalletViewModel(val app: Application) : AndroidViewModel(app) { msg.put("args", args) args.put("url", url) + this.payStatus.value = PayStatus.Loading() + myAkono.sendMessage(msg.toString()) } @@ -328,4 +342,29 @@ class WalletViewModel(val app: Application) : AndroidViewModel(app) { getBalances() } + + fun startTunnel() { + val msg = JSONObject() + msg.put("operation", "startTunnel") + + myAkono.sendMessage(msg.toString()) + } + + fun stopTunnel() { + val msg = JSONObject() + msg.put("operation", "stopTunnel") + + myAkono.sendMessage(msg.toString()) + } + + fun tunnelResponse(resp: String) { + val respJson = JSONObject(resp) + + val msg = JSONObject() + msg.put("operation", "tunnelResponse") + msg.put("args", respJson) + + myAkono.sendMessage(msg.toString()) + + } }
\ No newline at end of file |