summaryrefslogtreecommitdiff
path: root/cashier/src/main/java
diff options
context:
space:
mode:
authorTorsten Grote <t@grobox.de>2020-03-18 14:24:41 -0300
committerTorsten Grote <t@grobox.de>2020-03-18 14:24:41 -0300
commita4796ec47d89a851b260b6fc195494547208a025 (patch)
treed2941b68ff2ce22c523e7aa634965033b1100560 /cashier/src/main/java
downloadtaler-android-a4796ec47d89a851b260b6fc195494547208a025.tar.gz
taler-android-a4796ec47d89a851b260b6fc195494547208a025.tar.bz2
taler-android-a4796ec47d89a851b260b6fc195494547208a025.zip
Merge all three apps into one repository
Diffstat (limited to 'cashier/src/main/java')
-rw-r--r--cashier/src/main/java/net/taler/cashier/Amount.kt45
-rw-r--r--cashier/src/main/java/net/taler/cashier/BalanceFragment.kt182
-rw-r--r--cashier/src/main/java/net/taler/cashier/ConfigFragment.kt139
-rw-r--r--cashier/src/main/java/net/taler/cashier/HttpHelper.kt102
-rw-r--r--cashier/src/main/java/net/taler/cashier/MainActivity.kt62
-rw-r--r--cashier/src/main/java/net/taler/cashier/MainViewModel.kt148
-rw-r--r--cashier/src/main/java/net/taler/cashier/Utils.kt91
-rw-r--r--cashier/src/main/java/net/taler/cashier/withdraw/ErrorFragment.kt55
-rw-r--r--cashier/src/main/java/net/taler/cashier/withdraw/NfcManager.kt234
-rw-r--r--cashier/src/main/java/net/taler/cashier/withdraw/QrCodeManager.kt42
-rw-r--r--cashier/src/main/java/net/taler/cashier/withdraw/TransactionFragment.kt174
-rw-r--r--cashier/src/main/java/net/taler/cashier/withdraw/WithdrawManager.kt232
12 files changed, 1506 insertions, 0 deletions
diff --git a/cashier/src/main/java/net/taler/cashier/Amount.kt b/cashier/src/main/java/net/taler/cashier/Amount.kt
new file mode 100644
index 0000000..2c237c8
--- /dev/null
+++ b/cashier/src/main/java/net/taler/cashier/Amount.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.cashier
+
+data class Amount(val currency: String, val amount: String) {
+
+ companion object {
+
+ private val SIGNED_REGEX = Regex("""([+\-])(\w+):([0-9.]+)""")
+
+ @Suppress("unused")
+ fun fromString(strAmount: String): Amount {
+ val components = strAmount.split(":")
+ return Amount(components[0], components[1])
+ }
+
+ fun fromStringSigned(strAmount: String): Amount? {
+ val groups = SIGNED_REGEX.matchEntire(strAmount)?.groupValues ?: emptyList()
+ if (groups.size < 4) return null
+ var amount = groups[3].toDoubleOrNull() ?: return null
+ if (groups[1] == "-") amount *= -1
+ val currency = groups[2]
+ val amountStr = amount.toString()
+ // only display as many digits as required to precisely render the balance
+ return Amount(currency, amountStr.removeSuffix(".0"))
+ }
+ }
+
+ override fun toString() = "$amount $currency"
+
+}
diff --git a/cashier/src/main/java/net/taler/cashier/BalanceFragment.kt b/cashier/src/main/java/net/taler/cashier/BalanceFragment.kt
new file mode 100644
index 0000000..b3a0221
--- /dev/null
+++ b/cashier/src/main/java/net/taler/cashier/BalanceFragment.kt
@@ -0,0 +1,182 @@
+/*
+ * 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.cashier
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.View.VISIBLE
+import android.view.ViewGroup
+import android.view.inputmethod.EditorInfo
+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_balance.*
+import net.taler.cashier.BalanceFragmentDirections.Companion.actionBalanceFragmentToTransactionFragment
+import net.taler.cashier.withdraw.LastTransaction
+import net.taler.cashier.withdraw.WithdrawStatus
+
+sealed class BalanceResult {
+ object Error : BalanceResult()
+ object Offline : BalanceResult()
+ class Success(val amount: Amount) : BalanceResult()
+}
+
+class BalanceFragment : Fragment() {
+
+ private val viewModel: MainViewModel by activityViewModels()
+ private val withdrawManager by lazy { viewModel.withdrawManager }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ setHasOptionsMenu(true)
+ return inflater.inflate(R.layout.fragment_balance, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ withdrawManager.lastTransaction.observe(viewLifecycleOwner, Observer { lastTransaction ->
+ onLastTransaction(lastTransaction)
+ })
+ viewModel.balance.observe(viewLifecycleOwner, Observer { result ->
+ when (result) {
+ is BalanceResult.Success -> onBalanceUpdated(result.amount)
+ else -> onBalanceUpdated(null, result is BalanceResult.Offline)
+ }
+ })
+ button5.setOnClickListener { onAmountButtonPressed(5) }
+ button10.setOnClickListener { onAmountButtonPressed(10) }
+ button20.setOnClickListener { onAmountButtonPressed(20) }
+ button50.setOnClickListener { onAmountButtonPressed(50) }
+
+ if (savedInstanceState != null) {
+ amountView.editText!!.setText(savedInstanceState.getCharSequence("amountView"))
+ }
+ amountView.editText!!.setOnEditorActionListener { _, actionId, _ ->
+ if (actionId == EditorInfo.IME_ACTION_GO) {
+ onAmountConfirmed(getAmountFromView())
+ true
+ } else false
+ }
+ viewModel.currency.observe(viewLifecycleOwner, Observer { currency ->
+ currencyView.text = currency
+ })
+ confirmWithdrawalButton.setOnClickListener { onAmountConfirmed(getAmountFromView()) }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ // update balance if there's a config
+ if (viewModel.hasConfig()) {
+ viewModel.getBalance()
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ // for some reason automatic restore isn't working at the moment!?
+ amountView?.editText?.text.let {
+ outState.putCharSequence("amountView", it)
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
+ inflater.inflate(R.menu.balance, menu)
+ super.onCreateOptionsMenu(menu, inflater)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.action_reconfigure -> {
+ findNavController().navigate(viewModel.configDestination)
+ true
+ }
+ R.id.action_lock -> {
+ viewModel.lock()
+ findNavController().navigate(viewModel.configDestination)
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ private fun onBalanceUpdated(amount: Amount?, isOffline: Boolean = false) {
+ val uiList = listOf(
+ introView,
+ button5, button10, button20, button50,
+ amountView, currencyView, confirmWithdrawalButton
+ )
+ if (amount == null) {
+ balanceView.text =
+ getString(if (isOffline) R.string.balance_offline else R.string.balance_error)
+ uiList.forEach { it.fadeOut() }
+ } else {
+ @SuppressLint("SetTextI18n")
+ balanceView.text = "${amount.amount} ${amount.currency}"
+ uiList.forEach { it.fadeIn() }
+ }
+ progressBar.fadeOut()
+ }
+
+ private fun onAmountButtonPressed(amount: Int) {
+ amountView.editText!!.setText(amount.toString())
+ amountView.error = null
+ }
+
+ private fun getAmountFromView(): Int {
+ val str = amountView.editText!!.text.toString()
+ if (str.isBlank()) return 0
+ return Integer.parseInt(str)
+ }
+
+ private fun onAmountConfirmed(amount: Int) {
+ if (amount <= 0) {
+ amountView.error = getString(R.string.withdraw_error_zero)
+ } else if (!withdrawManager.hasSufficientBalance(amount)) {
+ amountView.error = getString(R.string.withdraw_error_insufficient_balance)
+ } else {
+ amountView.error = null
+ withdrawManager.withdraw(amount)
+ actionBalanceFragmentToTransactionFragment().let {
+ findNavController().navigate(it)
+ }
+ }
+ }
+
+ private fun onLastTransaction(lastTransaction: LastTransaction?) {
+ val status = lastTransaction?.withdrawStatus
+ val text = when (status) {
+ is WithdrawStatus.Success -> getString(
+ R.string.transaction_last_success, lastTransaction.withdrawAmount
+ )
+ is WithdrawStatus.Aborted -> getString(R.string.transaction_last_aborted)
+ else -> getString(R.string.transaction_last_error)
+ }
+ lastTransactionView.text = text
+ val drawable = if (status == WithdrawStatus.Success)
+ R.drawable.ic_check_circle
+ else
+ R.drawable.ic_error
+ lastTransactionView.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, 0, 0, 0)
+ lastTransactionView.visibility = VISIBLE
+ }
+
+}
diff --git a/cashier/src/main/java/net/taler/cashier/ConfigFragment.kt b/cashier/src/main/java/net/taler/cashier/ConfigFragment.kt
new file mode 100644
index 0000000..b9a97e5
--- /dev/null
+++ b/cashier/src/main/java/net/taler/cashier/ConfigFragment.kt
@@ -0,0 +1,139 @@
+/*
+ * 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.cashier
+
+import android.os.Bundle
+import android.text.method.LinkMovementMethod
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.INVISIBLE
+import android.view.View.VISIBLE
+import android.view.ViewGroup
+import android.view.inputmethod.InputMethodManager
+import androidx.core.content.ContextCompat.getSystemService
+import androidx.core.text.HtmlCompat
+import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Observer
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.snackbar.Snackbar
+import com.google.android.material.snackbar.Snackbar.LENGTH_LONG
+import kotlinx.android.synthetic.main.fragment_config.*
+
+private const val URL_BANK_TEST = "https://bank.test.taler.net"
+private const val URL_BANK_TEST_REGISTER = "$URL_BANK_TEST/accounts/register"
+
+class ConfigFragment : Fragment() {
+
+ private val viewModel: MainViewModel by activityViewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_config, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ if (savedInstanceState == null) {
+ if (viewModel.config.bankUrl.isBlank()) {
+ urlView.editText!!.setText(URL_BANK_TEST)
+ } else {
+ urlView.editText!!.setText(viewModel.config.bankUrl)
+ }
+ usernameView.editText!!.setText(viewModel.config.username)
+ passwordView.editText!!.setText(viewModel.config.password)
+ } else {
+ urlView.editText!!.setText(savedInstanceState.getCharSequence("urlView"))
+ usernameView.editText!!.setText(savedInstanceState.getCharSequence("usernameView"))
+ passwordView.editText!!.setText(savedInstanceState.getCharSequence("passwordView"))
+ }
+ saveButton.setOnClickListener {
+ val config = Config(
+ bankUrl = urlView.editText!!.text.toString(),
+ username = usernameView.editText!!.text.toString(),
+ password = passwordView.editText!!.text.toString()
+ )
+ if (checkConfig(config)) {
+ // show progress
+ saveButton.visibility = INVISIBLE
+ progressBar.visibility = VISIBLE
+ // kick off check and observe result
+ viewModel.checkAndSaveConfig(config)
+ viewModel.configResult.observe(viewLifecycleOwner, onConfigResult)
+ // hide keyboard
+ val inputMethodManager =
+ getSystemService(requireContext(), InputMethodManager::class.java)!!
+ inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0)
+ }
+ }
+ demoView.text = HtmlCompat.fromHtml(
+ getString(R.string.config_demo_hint, URL_BANK_TEST_REGISTER), FROM_HTML_MODE_LEGACY
+ )
+ demoView.movementMethod = LinkMovementMethod.getInstance()
+ }
+
+ override fun onStart() {
+ super.onStart()
+ // focus on password if it is the only missing value (like after locking)
+ if (urlView.editText!!.text.isNotBlank()
+ && usernameView.editText!!.text.isNotBlank()
+ && passwordView.editText!!.text.isBlank()) {
+ passwordView.editText!!.requestFocus()
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ // for some reason automatic restore isn't working at the moment!?
+ outState.putCharSequence("urlView", urlView.editText?.text)
+ outState.putCharSequence("usernameView", usernameView.editText?.text)
+ outState.putCharSequence("passwordView", passwordView.editText?.text)
+ }
+
+ private fun checkConfig(config: Config): Boolean {
+ if (!config.bankUrl.startsWith("https://")) {
+ urlView.error = getString(R.string.config_bank_url_error)
+ urlView.requestFocus()
+ return false
+ }
+ if (config.username.isBlank()) {
+ usernameView.error = getString(R.string.config_username_error)
+ usernameView.requestFocus()
+ return false
+ }
+ urlView.isErrorEnabled = false
+ return true
+ }
+
+ private val onConfigResult = Observer<ConfigResult> { result ->
+ if (result == null) return@Observer
+ if (result.success) {
+ val action = ConfigFragmentDirections.actionConfigFragmentToBalanceFragment()
+ findNavController().navigate(action)
+ } else {
+ val res = if (result.authError) R.string.config_error_auth else R.string.config_error
+ Snackbar.make(view!!, res, LENGTH_LONG).show()
+ }
+ saveButton.visibility = VISIBLE
+ progressBar.visibility = INVISIBLE
+ viewModel.configResult.removeObservers(viewLifecycleOwner)
+ }
+
+}
diff --git a/cashier/src/main/java/net/taler/cashier/HttpHelper.kt b/cashier/src/main/java/net/taler/cashier/HttpHelper.kt
new file mode 100644
index 0000000..06b06db
--- /dev/null
+++ b/cashier/src/main/java/net/taler/cashier/HttpHelper.kt
@@ -0,0 +1,102 @@
+/*
+ * 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.cashier
+
+import android.util.Log
+import androidx.annotation.WorkerThread
+import okhttp3.Credentials
+import okhttp3.MediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody
+import org.json.JSONObject
+
+object HttpHelper {
+
+ private val TAG = HttpHelper::class.java.simpleName
+ private const val MIME_TYPE_JSON = "application/json"
+
+ @WorkerThread
+ fun makeJsonGetRequest(url: String, config: Config): HttpJsonResult {
+ val request = Request.Builder()
+ .addHeader("Accept", MIME_TYPE_JSON)
+ .url(url)
+ .get()
+ .build()
+ val response = try {
+ getHttpClient(config.username, config.password)
+ .newCall(request)
+ .execute()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error retrieving $url", e)
+ return HttpJsonResult.Error(500)
+ }
+ return if (response.code() == 200 && response.body() != null) {
+ val jsonObject = JSONObject(response.body()!!.string())
+ HttpJsonResult.Success(jsonObject)
+ } else {
+ Log.e(TAG, "Received status ${response.code()} from $url expected 200")
+ HttpJsonResult.Error(response.code())
+ }
+ }
+
+ private val MEDIA_TYPE_JSON = MediaType.parse("$MIME_TYPE_JSON; charset=utf-8")
+
+ @WorkerThread
+ fun makeJsonPostRequest(url: String, body: String, config: Config): HttpJsonResult {
+ val request = Request.Builder()
+ .addHeader("Accept", MIME_TYPE_JSON)
+ .url(url)
+ .post(RequestBody.create(MEDIA_TYPE_JSON, body))
+ .build()
+ val response = try {
+ getHttpClient(config.username, config.password)
+ .newCall(request)
+ .execute()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error retrieving $url", e)
+ return HttpJsonResult.Error(500)
+ }
+ return if (response.code() == 200 && response.body() != null) {
+ val jsonObject = JSONObject(response.body()!!.string())
+ HttpJsonResult.Success(jsonObject)
+ } else {
+ Log.e(TAG, "Received status ${response.code()} from $url expected 200")
+ HttpJsonResult.Error(response.code())
+ }
+ }
+
+ private fun getHttpClient(username: String, password: String) =
+ OkHttpClient.Builder().authenticator { _, response ->
+ val credential = Credentials.basic(username, password)
+ if (credential == response.request().header("Authorization")) {
+ // If we already failed with these credentials, don't retry
+ return@authenticator null
+ }
+ response
+ .request()
+ .newBuilder()
+ .header("Authorization", credential)
+ .build()
+ }.build()
+
+}
+
+sealed class HttpJsonResult {
+ class Error(val statusCode: Int) : HttpJsonResult()
+ class Success(val json: JSONObject) : HttpJsonResult()
+}
diff --git a/cashier/src/main/java/net/taler/cashier/MainActivity.kt b/cashier/src/main/java/net/taler/cashier/MainActivity.kt
new file mode 100644
index 0000000..b238054
--- /dev/null
+++ b/cashier/src/main/java/net/taler/cashier/MainActivity.kt
@@ -0,0 +1,62 @@
+/*
+ * 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.cashier
+
+import android.content.Intent
+import android.content.Intent.*
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.navigation.NavController
+import androidx.navigation.fragment.NavHostFragment
+import kotlinx.android.synthetic.main.activity_main.*
+
+class MainActivity : AppCompatActivity() {
+
+ private val viewModel: MainViewModel by viewModels()
+ private lateinit var nav: NavController
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+ setSupportActionBar(toolbar)
+ val navHostFragment =
+ supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
+ nav = navHostFragment.navController
+ }
+
+ override fun onStart() {
+ super.onStart()
+ if (!viewModel.hasConfig()) {
+ nav.navigate(viewModel.configDestination)
+ }
+ }
+
+ override fun onBackPressed() {
+ if (!viewModel.hasConfig() && nav.currentDestination?.id == R.id.configFragment) {
+ // we are in the configuration screen and need a config to continue
+ val intent = Intent(ACTION_MAIN).apply {
+ addCategory(CATEGORY_HOME)
+ flags = FLAG_ACTIVITY_NEW_TASK
+ }
+ startActivity(intent)
+ } else {
+ super.onBackPressed()
+ }
+ }
+
+}
diff --git a/cashier/src/main/java/net/taler/cashier/MainViewModel.kt b/cashier/src/main/java/net/taler/cashier/MainViewModel.kt
new file mode 100644
index 0000000..3874038
--- /dev/null
+++ b/cashier/src/main/java/net/taler/cashier/MainViewModel.kt
@@ -0,0 +1,148 @@
+/*
+ * 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.cashier
+
+import android.annotation.SuppressLint
+import android.app.Application
+import android.util.Log
+import androidx.annotation.UiThread
+import androidx.annotation.WorkerThread
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.viewModelScope
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV
+import androidx.security.crypto.EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+import androidx.security.crypto.MasterKeys
+import androidx.security.crypto.MasterKeys.AES256_GCM_SPEC
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import net.taler.cashier.Amount.Companion.fromStringSigned
+import net.taler.cashier.HttpHelper.makeJsonGetRequest
+import net.taler.cashier.withdraw.WithdrawManager
+
+private val TAG = MainViewModel::class.java.simpleName
+
+private const val PREF_NAME = "net.taler.cashier.prefs"
+private const val PREF_KEY_BANK_URL = "bankUrl"
+private const val PREF_KEY_USERNAME = "username"
+private const val PREF_KEY_PASSWORD = "password"
+private const val PREF_KEY_CURRENCY = "currency"
+
+class MainViewModel(private val app: Application) : AndroidViewModel(app) {
+
+ val configDestination = ConfigFragmentDirections.actionGlobalConfigFragment()
+
+ private val masterKeyAlias = MasterKeys.getOrCreate(AES256_GCM_SPEC)
+ private val prefs = EncryptedSharedPreferences.create(
+ PREF_NAME, masterKeyAlias, app, AES256_SIV, AES256_GCM
+ )
+
+ internal var config = Config(
+ bankUrl = prefs.getString(PREF_KEY_BANK_URL, "")!!,
+ username = prefs.getString(PREF_KEY_USERNAME, "")!!,
+ password = prefs.getString(PREF_KEY_PASSWORD, "")!!
+ )
+
+ private val mCurrency = MutableLiveData<String>(
+ prefs.getString(PREF_KEY_CURRENCY, null)
+ )
+ internal val currency: LiveData<String> = mCurrency
+
+ private val mConfigResult = MutableLiveData<ConfigResult>()
+ val configResult: LiveData<ConfigResult> = mConfigResult
+
+ private val mBalance = MutableLiveData<BalanceResult>()
+ val balance: LiveData<BalanceResult> = mBalance
+
+ internal val withdrawManager = WithdrawManager(app, this)
+
+ fun hasConfig() = config.bankUrl.isNotEmpty()
+ && config.username.isNotEmpty()
+ && config.password.isNotEmpty()
+
+ /**
+ * Start observing [configResult] after calling this to get the result async.
+ * Warning: Ignore null results that are used to reset old results.
+ */
+ @UiThread
+ fun checkAndSaveConfig(config: Config) {
+ mConfigResult.value = null
+ viewModelScope.launch(Dispatchers.IO) {
+ val url = "${config.bankUrl}/accounts/${config.username}/balance"
+ Log.d(TAG, "Checking config: $url")
+ val result = when (val response = makeJsonGetRequest(url, config)) {
+ is HttpJsonResult.Success -> {
+ val balance = response.json.getString("balance")
+ val amount = fromStringSigned(balance)!!
+ mCurrency.postValue(amount.currency)
+ prefs.edit().putString(PREF_KEY_CURRENCY, amount.currency).apply()
+ // save config
+ saveConfig(config)
+ ConfigResult(true)
+ }
+ is HttpJsonResult.Error -> {
+ val authError = response.statusCode == 401
+ ConfigResult(false, authError)
+ }
+ }
+ mConfigResult.postValue(result)
+ }
+ }
+
+ @WorkerThread
+ @SuppressLint("ApplySharedPref")
+ private fun saveConfig(config: Config) {
+ this.config = config
+ prefs.edit()
+ .putString(PREF_KEY_BANK_URL, config.bankUrl)
+ .putString(PREF_KEY_USERNAME, config.username)
+ .putString(PREF_KEY_PASSWORD, config.password)
+ .commit()
+ }
+
+ fun getBalance() = viewModelScope.launch(Dispatchers.IO) {
+ check(hasConfig()) { "No config to get balance" }
+ val url = "${config.bankUrl}/accounts/${config.username}/balance"
+ Log.d(TAG, "Checking balance at $url")
+ val result = when (val response = makeJsonGetRequest(url, config)) {
+ is HttpJsonResult.Success -> {
+ val balance = response.json.getString("balance")
+ fromStringSigned(balance)?.let { BalanceResult.Success(it) } ?: BalanceResult.Error
+ }
+ is HttpJsonResult.Error -> {
+ if (app.isOnline()) BalanceResult.Error
+ else BalanceResult.Offline
+ }
+ }
+ mBalance.postValue(result)
+ }
+
+ fun lock() {
+ saveConfig(config.copy(password = ""))
+ }
+
+}
+
+data class Config(
+ val bankUrl: String,
+ val username: String,
+ val password: String
+)
+
+class ConfigResult(val success: Boolean, val authError: Boolean = false)
diff --git a/cashier/src/main/java/net/taler/cashier/Utils.kt b/cashier/src/main/java/net/taler/cashier/Utils.kt
new file mode 100644
index 0000000..62f7a77
--- /dev/null
+++ b/cashier/src/main/java/net/taler/cashier/Utils.kt
@@ -0,0 +1,91 @@
+/*
+ * 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.cashier
+
+import android.content.Context
+import android.content.Context.CONNECTIVITY_SERVICE
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.os.Build.VERSION.SDK_INT
+import android.view.View
+import android.view.View.INVISIBLE
+import android.view.View.VISIBLE
+
+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()
+
+ @Suppress("unused")
+ 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 View.fadeIn(endAction: () -> Unit = {}) {
+ if (visibility == VISIBLE) return
+ alpha = 0f
+ visibility = VISIBLE
+ animate().alpha(1f).withEndAction {
+ endAction.invoke()
+ }.start()
+}
+
+fun View.fadeOut(endAction: () -> Unit = {}) {
+ if (visibility == INVISIBLE) return
+ animate().alpha(0f).withEndAction {
+ visibility = INVISIBLE
+ alpha = 1f
+ endAction.invoke()
+ }.start()
+}
+
+fun Context.isOnline(): Boolean {
+ val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
+ return if (SDK_INT < 29) {
+ @Suppress("DEPRECATION")
+ cm.activeNetworkInfo?.isConnected == true
+ } else {
+ val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
+ capabilities.hasCapability(NET_CAPABILITY_INTERNET)
+ }
+}
diff --git a/cashier/src/main/java/net/taler/cashier/withdraw/ErrorFragment.kt b/cashier/src/main/java/net/taler/cashier/withdraw/ErrorFragment.kt
new file mode 100644
index 0000000..ceffcec
--- /dev/null
+++ b/cashier/src/main/java/net/taler/cashier/withdraw/ErrorFragment.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.cashier.withdraw
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+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_error.*
+import net.taler.cashier.MainViewModel
+import net.taler.cashier.R
+
+class ErrorFragment : Fragment() {
+
+ private val viewModel: MainViewModel by activityViewModels()
+ private val withdrawManager by lazy { viewModel.withdrawManager }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_error, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ withdrawManager.withdrawStatus.observe(viewLifecycleOwner, Observer { status ->
+ if (status is WithdrawStatus.Aborted) {
+ textView.setText(R.string.transaction_aborted)
+ }
+ })
+ withdrawManager.completeTransaction()
+ backButton.setOnClickListener {
+ findNavController().popBackStack()
+ }
+ }
+
+}
diff --git a/cashier/src/main/java/net/taler/cashier/withdraw/NfcManager.kt b/cashier/src/main/java/net/taler/cashier/withdraw/NfcManager.kt
new file mode 100644
index 0000000..a487b5f
--- /dev/null
+++ b/cashier/src/main/java/net/taler/cashier/withdraw/NfcManager.kt
@@ -0,0 +1,234 @@
+/*
+ * 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.cashier.withdraw
+
+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.NfcAdapter.getDefaultAdapter
+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
+
+@Suppress("unused")
+private const val TALER_AID = "A0000002471001"
+
+class NfcManager : NfcAdapter.ReaderCallback {
+
+ companion object {
+ const val TAG = "taler-merchant"
+
+ /**
+ * Returns true if NFC is supported and false otherwise.
+ */
+ fun hasNfc(context: Context): Boolean {
+ return getNfcAdapter(context) != null
+ }
+
+ /**
+ * 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 getDefaultAdapter(context)
+ }
+ }
+
+ 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/cashier/src/main/java/net/taler/cashier/withdraw/QrCodeManager.kt b/cashier/src/main/java/net/taler/cashier/withdraw/QrCodeManager.kt
new file mode 100644
index 0000000..e3ffa92
--- /dev/null
+++ b/cashier/src/main/java/net/taler/cashier/withdraw/QrCodeManager.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.cashier.withdraw
+
+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/cashier/src/main/java/net/taler/cashier/withdraw/TransactionFragment.kt b/cashier/src/main/java/net/taler/cashier/withdraw/TransactionFragment.kt
new file mode 100644
index 0000000..8b782b0
--- /dev/null
+++ b/cashier/src/main/java/net/taler/cashier/withdraw/TransactionFragment.kt
@@ -0,0 +1,174 @@
+/*
+ * 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.cashier.withdraw
+
+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.MainViewModel
+import net.taler.cashier.R
+import net.taler.cashier.fadeIn
+import net.taler.cashier.fadeOut
+import net.taler.cashier.withdraw.TransactionFragmentDirections.Companion.actionTransactionFragmentToBalanceFragment
+import net.taler.cashier.withdraw.TransactionFragmentDirections.Companion.actionTransactionFragmentToErrorFragment
+import net.taler.cashier.withdraw.WithdrawResult.Error
+import net.taler.cashier.withdraw.WithdrawResult.InsufficientBalance
+import net.taler.cashier.withdraw.WithdrawResult.Success
+
+class TransactionFragment : Fragment() {
+
+ private val viewModel: MainViewModel by activityViewModels()
+ private val withdrawManager by lazy { viewModel.withdrawManager }
+ private val nfcManager = NfcManager()
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_transaction, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ withdrawManager.withdrawAmount.observe(viewLifecycleOwner, Observer { amount ->
+ amountView.text = amount
+ })
+ withdrawManager.withdrawResult.observe(viewLifecycleOwner, Observer { result ->
+ onWithdrawResultReceived(result)
+ })
+ withdrawManager.withdrawStatus.observe(viewLifecycleOwner, Observer { status ->
+ onWithdrawStatusChanged(status)
+ })
+
+ // change intro text depending on whether NFC is available or not
+ val hasNfc = NfcManager.hasNfc(requireContext())
+ val intro = if (hasNfc) R.string.transaction_intro_nfc else R.string.transaction_intro
+ introView.setText(intro)
+
+ cancelButton.setOnClickListener {
+ findNavController().popBackStack()
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ if (withdrawManager.withdrawResult.value is Success) {
+ NfcManager.start(requireActivity(), nfcManager)
+ }
+ }
+
+ override fun onStop() {
+ super.onStop()
+ NfcManager.stop(requireActivity())
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ if (!requireActivity().isChangingConfigurations) {
+ withdrawManager.abort()
+ }
+ }
+
+ private fun onWithdrawResultReceived(result: WithdrawResult?) {
+ 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()
+ }
+ }
+ }
+
+ private fun onWithdrawStatusChanged(status: WithdrawStatus?): Any = when (status) {
+ is WithdrawStatus.SelectionDone -> {
+ qrCodeView.fadeOut {
+ qrCodeView?.setImageResource(R.drawable.ic_arrow)
+ qrCodeView?.fadeIn()
+ }
+ introView.fadeOut {
+ introView?.text = getString(R.string.transaction_intro_scanned)
+ introView?.fadeIn {
+ confirmButton?.isEnabled = true
+ confirmButton?.setOnClickListener {
+ withdrawManager.confirm(status.withdrawalId)
+ }
+ }
+ }
+ }
+ is WithdrawStatus.Confirming -> {
+ confirmButton.isEnabled = false
+ qrCodeView.fadeOut()
+ progressBar.fadeIn()
+ }
+ is WithdrawStatus.Success -> {
+ withdrawManager.completeTransaction()
+ actionTransactionFragmentToBalanceFragment().let {
+ findNavController().navigate(it)
+ }
+ }
+ is WithdrawStatus.Aborted -> onError()
+ is WithdrawStatus.Error -> onError()
+ null -> {
+ // no-op
+ }
+ }
+
+ private fun onError() {
+ actionTransactionFragmentToErrorFragment().let {
+ findNavController().navigate(it)
+ }
+ }
+
+}
diff --git a/cashier/src/main/java/net/taler/cashier/withdraw/WithdrawManager.kt b/cashier/src/main/java/net/taler/cashier/withdraw/WithdrawManager.kt
new file mode 100644
index 0000000..4c618ac
--- /dev/null
+++ b/cashier/src/main/java/net/taler/cashier/withdraw/WithdrawManager.kt
@@ -0,0 +1,232 @@
+/*
+ * 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.cashier.withdraw
+
+import android.app.Application
+import android.graphics.Bitmap
+import android.os.CountDownTimer
+import android.util.Log
+import androidx.annotation.UiThread
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import net.taler.cashier.BalanceResult
+import net.taler.cashier.HttpHelper.makeJsonGetRequest
+import net.taler.cashier.HttpHelper.makeJsonPostRequest
+import net.taler.cashier.HttpJsonResult.Error
+import net.taler.cashier.HttpJsonResult.Success
+import net.taler.cashier.MainViewModel
+import net.taler.cashier.R
+import org.json.JSONObject
+import java.util.concurrent.TimeUnit.MINUTES
+import java.util.concurrent.TimeUnit.SECONDS
+
+private val TAG = WithdrawManager::class.java.simpleName
+
+private val INTERVAL = SECONDS.toMillis(1)
+private val TIMEOUT = MINUTES.toMillis(2)
+
+class WithdrawManager(
+ private val app: Application,
+ private val viewModel: MainViewModel
+) {
+ private val scope
+ get() = viewModel.viewModelScope
+
+ private val config
+ get() = viewModel.config
+
+ private val currency: String?
+ get() = viewModel.currency.value
+
+ private var withdrawStatusCheck: Job? = null
+
+ private val mWithdrawAmount = MutableLiveData<String>()
+ val withdrawAmount: LiveData<String> = mWithdrawAmount
+
+ private val mWithdrawResult = MutableLiveData<WithdrawResult>()
+ val withdrawResult: LiveData<WithdrawResult> = mWithdrawResult
+
+ private val mWithdrawStatus = MutableLiveData<WithdrawStatus>()
+ val withdrawStatus: LiveData<WithdrawStatus> = mWithdrawStatus
+
+ private val mLastTransaction = MutableLiveData<LastTransaction>()
+ val lastTransaction: LiveData<LastTransaction> = mLastTransaction
+
+ @UiThread
+ fun hasSufficientBalance(amount: Int): Boolean {
+ val balanceResult = viewModel.balance.value
+ if (balanceResult !is BalanceResult.Success) return false
+ val balanceStr = balanceResult.amount.amount
+ val balanceDouble = balanceStr.toDouble()
+ return amount <= balanceDouble
+ }
+
+ @UiThread
+ fun withdraw(amount: Int) {
+ check(amount > 0) { "Withdraw amount was <= 0" }
+ check(currency != null) { "Currency is null" }
+ mWithdrawResult.value = null
+ mWithdrawAmount.value = "$amount $currency"
+ scope.launch(Dispatchers.IO) {
+ val url = "${config.bankUrl}/accounts/${config.username}/withdrawals"
+ Log.d(TAG, "Starting withdrawal at $url")
+ val body = JSONObject(mapOf("amount" to "${currency}:${amount}")).toString()
+ when (val result = makeJsonPostRequest(url, body, config)) {
+ is Success -> {
+ val talerUri = result.json.getString("taler_withdraw_uri")
+ val withdrawResult = WithdrawResult.Success(
+ id = result.json.getString("withdrawal_id"),
+ talerUri = talerUri,
+ qrCode = QrCodeManager.makeQrCode(talerUri)
+ )
+ mWithdrawResult.postValue(withdrawResult)
+ timer.start()
+ }
+ is Error -> {
+ val errorStr = app.getString(R.string.withdraw_error_fetch)
+ mWithdrawResult.postValue(WithdrawResult.Error(errorStr))
+ }
+ }
+ }
+ }
+
+ private val timer: CountDownTimer = object : CountDownTimer(TIMEOUT, INTERVAL) {
+ override fun onTick(millisUntilFinished: Long) {
+ val result = withdrawResult.value
+ if (result is WithdrawResult.Success) {
+ // check for active jobs and only do one at a time
+ val hasActiveCheck = withdrawStatusCheck?.isActive ?: false
+ if (!hasActiveCheck) {
+ withdrawStatusCheck = checkWithdrawStatus(result.id)
+ }
+ } else {
+ cancel()
+ }
+ }
+
+ override fun onFinish() {
+ abort()
+ mWithdrawStatus.postValue(WithdrawStatus.Error)
+ cancel()
+ }
+ }
+
+ private fun checkWithdrawStatus(withdrawalId: String) = scope.launch(Dispatchers.IO) {
+ val url = "${config.bankUrl}/accounts/${config.username}/withdrawals/${withdrawalId}"
+ Log.d(TAG, "Checking withdraw status at $url")
+ val response = makeJsonGetRequest(url, config)
+ if (response !is Success) return@launch // ignore errors and continue trying
+ val oldStatus = mWithdrawStatus.value
+ when {
+ response.json.getBoolean("aborted") -> {
+ cancelWithdrawStatusCheck()
+ mWithdrawStatus.postValue(WithdrawStatus.Aborted)
+ }
+ response.json.getBoolean("confirmation_done") -> {
+ if (oldStatus !is WithdrawStatus.Success) {
+ cancelWithdrawStatusCheck()
+ mWithdrawStatus.postValue(WithdrawStatus.Success)
+ viewModel.getBalance()
+ }
+ }
+ response.json.getBoolean("selection_done") -> {
+ // only update status, if there's none, yet
+ // so we don't re-notify or overwrite newer status info
+ if (oldStatus == null) {
+ mWithdrawStatus.postValue(WithdrawStatus.SelectionDone(withdrawalId))
+ }
+ }
+ }
+ }
+
+ private fun cancelWithdrawStatusCheck() {
+ timer.cancel()
+ withdrawStatusCheck?.cancel()
+ }
+
+ /**
+ * Aborts the last [withdrawResult], if it exists und there is no [withdrawStatus].
+ * Otherwise this is a no-op.
+ */
+ @UiThread
+ fun abort() {
+ val result = withdrawResult.value
+ val status = withdrawStatus.value
+ if (result is WithdrawResult.Success && status == null) {
+ cancelWithdrawStatusCheck()
+ abort(result.id)
+ }
+ }
+
+ private fun abort(withdrawalId: String) = scope.launch(Dispatchers.IO) {
+ val url = "${config.bankUrl}/accounts/${config.username}/withdrawals/${withdrawalId}/abort"
+ Log.d(TAG, "Aborting withdrawal at $url")
+ makeJsonPostRequest(url, "", config)
+ }
+
+ @UiThread
+ fun confirm(withdrawalId: String) {
+ mWithdrawStatus.value = WithdrawStatus.Confirming
+ scope.launch(Dispatchers.IO) {
+ val url =
+ "${config.bankUrl}/accounts/${config.username}/withdrawals/${withdrawalId}/confirm"
+ Log.d(TAG, "Confirming withdrawal at $url")
+ when (val result = makeJsonPostRequest(url, "", config)) {
+ is Success -> {
+ // no-op still waiting for [timer] to confirm our confirmation
+ }
+ is Error -> {
+ Log.e(TAG, "Error confirming withdrawal. Status code: ${result.statusCode}")
+ mWithdrawStatus.postValue(WithdrawStatus.Error)
+ }
+ }
+ }
+ }
+
+ @UiThread
+ fun completeTransaction() {
+ mLastTransaction.value = LastTransaction(withdrawAmount.value!!, withdrawStatus.value!!)
+ withdrawStatusCheck = null
+ mWithdrawAmount.value = null
+ mWithdrawResult.value = null
+ mWithdrawStatus.value = null
+ }
+
+}
+
+sealed class WithdrawResult {
+ object InsufficientBalance : WithdrawResult()
+ class Error(val msg: String) : WithdrawResult()
+ class Success(val id: String, val talerUri: String, val qrCode: Bitmap) : WithdrawResult()
+}
+
+sealed class WithdrawStatus {
+ object Error : WithdrawStatus()
+ object Aborted : WithdrawStatus()
+ class SelectionDone(val withdrawalId: String) : WithdrawStatus()
+ object Confirming : WithdrawStatus()
+ object Success : WithdrawStatus()
+}
+
+data class LastTransaction(
+ val withdrawAmount: String,
+ val withdrawStatus: WithdrawStatus
+)