From a4796ec47d89a851b260b6fc195494547208a025 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 18 Mar 2020 14:24:41 -0300 Subject: Merge all three apps into one repository --- .../taler/wallet/payment/AlreadyPaidFragment.kt | 47 ++++++ .../java/net/taler/wallet/payment/ContractTerms.kt | 56 +++++++ .../net/taler/wallet/payment/PaymentManager.kt | 160 ++++++++++++++++++++ .../wallet/payment/PaymentSuccessfulFragment.kt | 49 ++++++ .../net/taler/wallet/payment/ProductAdapter.kt | 92 +++++++++++ .../taler/wallet/payment/ProductImageFragment.kt | 52 +++++++ .../taler/wallet/payment/PromptPaymentFragment.kt | 168 +++++++++++++++++++++ 7 files changed, 624 insertions(+) create mode 100644 wallet/src/main/java/net/taler/wallet/payment/AlreadyPaidFragment.kt create mode 100644 wallet/src/main/java/net/taler/wallet/payment/ContractTerms.kt create mode 100644 wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt create mode 100644 wallet/src/main/java/net/taler/wallet/payment/PaymentSuccessfulFragment.kt create mode 100644 wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt create mode 100644 wallet/src/main/java/net/taler/wallet/payment/ProductImageFragment.kt create mode 100644 wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt (limited to 'wallet/src/main/java/net/taler/wallet/payment') diff --git a/wallet/src/main/java/net/taler/wallet/payment/AlreadyPaidFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/AlreadyPaidFragment.kt new file mode 100644 index 0000000..33e3a1d --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/AlreadyPaidFragment.kt @@ -0,0 +1,47 @@ +/* + * 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 + */ + +package net.taler.wallet.payment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import kotlinx.android.synthetic.main.fragment_already_paid.* +import net.taler.wallet.R + +/** + * Display the message that the user already paid for the order + * that the merchant is proposing. + */ +class AlreadyPaidFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_already_paid, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + backButton.setOnClickListener { + findNavController().navigateUp() + } + } + +} diff --git a/wallet/src/main/java/net/taler/wallet/payment/ContractTerms.kt b/wallet/src/main/java/net/taler/wallet/payment/ContractTerms.kt new file mode 100644 index 0000000..da91dea --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/ContractTerms.kt @@ -0,0 +1,56 @@ +/* + * 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 + */ + +package net.taler.wallet.payment + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import net.taler.wallet.Amount + + +@JsonIgnoreProperties(ignoreUnknown = true) +data class ContractTerms( + val summary: String, + val products: List, + val amount: Amount +) + +interface Product { + val id: String? + val description: String + val price: Amount + val location: String? + val image: String? +} + +@JsonIgnoreProperties("totalPrice") +data class ContractProduct( + @JsonProperty("product_id") + override val id: String?, + override val description: String, + override val price: Amount, + @JsonProperty("delivery_location") + override val location: String?, + override val image: String?, + val quantity: Int +) : Product { + + val totalPrice: Amount by lazy { + val amount = price.amount.toDouble() * quantity + Amount(price.currency, amount.toString()) + } + +} diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt new file mode 100644 index 0000000..ee0edaf --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt @@ -0,0 +1,160 @@ +/* + * 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 + */ + +package net.taler.wallet.payment + +import android.util.Log +import androidx.annotation.UiThread +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import net.taler.wallet.Amount +import net.taler.wallet.TAG +import net.taler.wallet.backend.WalletBackendApi +import org.json.JSONObject +import java.net.MalformedURLException + +val REGEX_PRODUCT_IMAGE = Regex("^data:image/(jpeg|png);base64,([A-Za-z0-9+/=]+)$") + +class PaymentManager( + private val walletBackendApi: WalletBackendApi, + private val mapper: ObjectMapper +) { + + private val mPayStatus = MutableLiveData(PayStatus.None) + internal val payStatus: LiveData = mPayStatus + + private val mDetailsShown = MutableLiveData() + internal val detailsShown: LiveData = mDetailsShown + + private var currentPayRequestId = 0 + + @UiThread + fun preparePay(url: String) { + mPayStatus.value = PayStatus.Loading + mDetailsShown.value = false + + val args = JSONObject(mapOf("url" to url)) + + currentPayRequestId += 1 + val payRequestId = currentPayRequestId + + walletBackendApi.sendRequest("preparePay", args) { isError, result -> + when { + isError -> { + Log.v(TAG, "got preparePay error result") + mPayStatus.value = PayStatus.Error(result.toString()) + } + payRequestId != this.currentPayRequestId -> { + Log.v(TAG, "preparePay result was for old request") + } + else -> { + val status = result.getString("status") + try { + mPayStatus.postValue(getPayStatusUpdate(status, result)) + } catch (e: Exception) { + Log.e(TAG, "Error getting PayStatusUpdate", e) + mPayStatus.postValue(PayStatus.Error(e.message ?: "unknown error")) + } + } + } + } + } + + private fun getPayStatusUpdate(status: String, json: JSONObject) = when (status) { + "payment-possible" -> PayStatus.Prepared( + contractTerms = getContractTerms(json), + proposalId = json.getString("proposalId"), + totalFees = Amount.fromJson(json.getJSONObject("totalFees")) + ) + "paid" -> PayStatus.AlreadyPaid(getContractTerms(json)) + "insufficient-balance" -> PayStatus.InsufficientBalance(getContractTerms(json)) + "error" -> PayStatus.Error("got some error") + else -> PayStatus.Error("unknown status") + } + + private fun getContractTerms(json: JSONObject): ContractTerms { + val terms: ContractTerms = mapper.readValue(json.getString("contractTermsRaw")) + // validate product images + terms.products.forEach { product -> + product.image?.let { image -> + if (REGEX_PRODUCT_IMAGE.matchEntire(image) == null) { + throw MalformedURLException("Invalid image data URL for ${product.description}") + } + } + } + return terms + } + + @UiThread + fun toggleDetailsShown() { + val oldValue = mDetailsShown.value ?: false + mDetailsShown.value = !oldValue + } + + fun confirmPay(proposalId: String) { + val args = JSONObject(mapOf("proposalId" to proposalId)) + + walletBackendApi.sendRequest("confirmPay", args) { _, _ -> + mPayStatus.postValue(PayStatus.Success) + } + } + + @UiThread + fun abortPay() { + val ps = payStatus.value + if (ps is PayStatus.Prepared) { + abortProposal(ps.proposalId) + } + resetPayStatus() + } + + internal fun abortProposal(proposalId: String) { + val args = JSONObject(mapOf("proposalId" to proposalId)) + + Log.i(TAG, "aborting proposal") + + walletBackendApi.sendRequest("abortProposal", args) { isError, _ -> + if (isError) { + Log.e(TAG, "received error response to abortProposal") + return@sendRequest + } + mPayStatus.postValue(PayStatus.None) + } + } + + @UiThread + fun resetPayStatus() { + mPayStatus.value = PayStatus.None + } + +} + +sealed class PayStatus { + object None : PayStatus() + object Loading : PayStatus() + data class Prepared( + val contractTerms: ContractTerms, + val proposalId: String, + val totalFees: Amount + ) : PayStatus() + + data class InsufficientBalance(val contractTerms: ContractTerms) : PayStatus() + data class AlreadyPaid(val contractTerms: ContractTerms) : PayStatus() + data class Error(val error: String) : PayStatus() + object Success : PayStatus() +} diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentSuccessfulFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentSuccessfulFragment.kt new file mode 100644 index 0000000..2084c45 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/PaymentSuccessfulFragment.kt @@ -0,0 +1,49 @@ +/* + * 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 + */ + +package net.taler.wallet.payment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import kotlinx.android.synthetic.main.fragment_payment_successful.* +import net.taler.wallet.R +import net.taler.wallet.fadeIn + +/** + * Fragment that shows the success message for a payment. + */ +class PaymentSuccessfulFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_payment_successful, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + successImageView.fadeIn() + successTextView.fadeIn() + backButton.setOnClickListener { + findNavController().navigateUp() + } + } + +} diff --git a/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt b/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt new file mode 100644 index 0000000..4b1b062 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt @@ -0,0 +1,92 @@ +/* + * 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 + */ + +package net.taler.wallet.payment + +import android.graphics.Bitmap +import android.graphics.BitmapFactory.decodeByteArray +import android.util.Base64 +import android.view.LayoutInflater +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import net.taler.wallet.R +import net.taler.wallet.payment.ProductAdapter.ProductViewHolder + +internal interface ProductImageClickListener { + fun onImageClick(image: Bitmap) +} + +internal class ProductAdapter(private val listener: ProductImageClickListener) : + RecyclerView.Adapter() { + + private val items = ArrayList() + + override fun getItemCount() = items.size + + override fun getItemViewType(position: Int): Int { + return if (itemCount == 1) 1 else 0 + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProductViewHolder { + val res = + if (viewType == 1) R.layout.list_item_product_single else R.layout.list_item_product + val view = LayoutInflater.from(parent.context).inflate(res, parent, false) + return ProductViewHolder(view) + } + + override fun onBindViewHolder(holder: ProductViewHolder, position: Int) { + holder.bind(items[position]) + } + + fun setItems(items: List) { + this.items.clear() + this.items.addAll(items) + notifyDataSetChanged() + } + + internal inner class ProductViewHolder(v: View) : ViewHolder(v) { + private val quantity: TextView = v.findViewById(R.id.quantity) + private val image: ImageView = v.findViewById(R.id.image) + private val name: TextView = v.findViewById(R.id.name) + private val price: TextView = v.findViewById(R.id.price) + + fun bind(product: ContractProduct) { + quantity.text = product.quantity.toString() + if (product.image == null) { + image.visibility = GONE + } else { + image.visibility = VISIBLE + // product.image was validated before, so non-null below + val match = REGEX_PRODUCT_IMAGE.matchEntire(product.image)!! + val decodedString = Base64.decode(match.groups[2]!!.value, Base64.DEFAULT) + val bitmap = decodeByteArray(decodedString, 0, decodedString.size) + image.setImageBitmap(bitmap) + if (itemCount > 1) image.setOnClickListener { + listener.onImageClick(bitmap) + } + } + name.text = product.description + price.text = product.totalPrice.toString() + } + } + +} diff --git a/wallet/src/main/java/net/taler/wallet/payment/ProductImageFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/ProductImageFragment.kt new file mode 100644 index 0000000..02414a6 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/ProductImageFragment.kt @@ -0,0 +1,52 @@ +/* + * 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 + */ + +package net.taler.wallet.payment + +import android.graphics.Bitmap +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import kotlinx.android.synthetic.main.fragment_product_image.* +import net.taler.wallet.R + +class ProductImageFragment private constructor() : DialogFragment() { + + companion object { + private const val IMAGE = "image" + + fun new(image: Bitmap) = ProductImageFragment().apply { + arguments = Bundle().apply { + putParcelable(IMAGE, image) + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_product_image, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val bitmap = arguments!!.getParcelable(IMAGE) + productImageView.setImageBitmap(bitmap) + } + +} diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt new file mode 100644 index 0000000..44dcf26 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt @@ -0,0 +1,168 @@ +/* + * 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 + */ + +package net.taler.wallet.payment + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.lifecycle.observe +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.transition.TransitionManager.beginDelayedTransition +import kotlinx.android.synthetic.main.payment_bottom_bar.* +import kotlinx.android.synthetic.main.payment_details.* +import net.taler.wallet.Amount +import net.taler.wallet.R +import net.taler.wallet.WalletViewModel +import net.taler.wallet.fadeIn +import net.taler.wallet.fadeOut + +/** + * Show a payment and ask the user to accept/decline. + */ +class PromptPaymentFragment : Fragment(), ProductImageClickListener { + + private val model: WalletViewModel by activityViewModels() + private val paymentManager by lazy { model.paymentManager } + private val adapter = ProductAdapter(this) + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_prompt_payment, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + paymentManager.payStatus.observe(viewLifecycleOwner, this::onPaymentStatusChanged) + paymentManager.detailsShown.observe(viewLifecycleOwner, Observer { shown -> + beginDelayedTransition(view as ViewGroup) + val res = if (shown) R.string.payment_hide_details else R.string.payment_show_details + detailsButton.setText(res) + productsList.visibility = if (shown) VISIBLE else GONE + }) + + detailsButton.setOnClickListener { + paymentManager.toggleDetailsShown() + } + productsList.apply { + adapter = this@PromptPaymentFragment.adapter + layoutManager = LinearLayoutManager(requireContext()) + } + + abortButton.setOnClickListener { + paymentManager.abortPay() + findNavController().navigateUp() + } + } + + override fun onDestroy() { + super.onDestroy() + if (!requireActivity().isChangingConfigurations) { + paymentManager.abortPay() + } + } + + private fun showLoading(show: Boolean) { + model.showProgressBar.value = show + if (show) { + progressBar.fadeIn() + } else { + progressBar.fadeOut() + } + } + + private fun onPaymentStatusChanged(payStatus: PayStatus) { + when (payStatus) { + is PayStatus.Prepared -> { + showLoading(false) + showOrder(payStatus.contractTerms, payStatus.totalFees) + confirmButton.isEnabled = true + confirmButton.setOnClickListener { + model.showProgressBar.value = true + paymentManager.confirmPay(payStatus.proposalId) + confirmButton.fadeOut() + confirmProgressBar.fadeIn() + } + } + is PayStatus.InsufficientBalance -> { + showLoading(false) + showOrder(payStatus.contractTerms, null) + errorView.setText(R.string.payment_balance_insufficient) + errorView.fadeIn() + } + is PayStatus.Success -> { + showLoading(false) + paymentManager.resetPayStatus() + findNavController().navigate(R.id.action_promptPayment_to_paymentSuccessful) + } + is PayStatus.AlreadyPaid -> { + showLoading(false) + paymentManager.resetPayStatus() + findNavController().navigate(R.id.action_promptPayment_to_alreadyPaid) + } + is PayStatus.Error -> { + showLoading(false) + errorView.text = getString(R.string.payment_error, payStatus.error) + errorView.fadeIn() + } + is PayStatus.None -> { + // No payment active. + showLoading(false) + } + is PayStatus.Loading -> { + // Wait until loaded ... + showLoading(true) + } + } + } + + private fun showOrder(contractTerms: ContractTerms, totalFees: Amount?) { + orderView.text = contractTerms.summary + adapter.setItems(contractTerms.products) + if (contractTerms.products.size == 1) paymentManager.toggleDetailsShown() + val amount = contractTerms.amount + @SuppressLint("SetTextI18n") + totalView.text = "${amount.amount} ${amount.currency}" + if (totalFees != null && !totalFees.isZero()) { + val fee = "${totalFees.amount} ${totalFees.currency}" + feeView.text = getString(R.string.payment_fee, fee) + feeView.fadeIn() + } else { + feeView.visibility = GONE + } + orderLabelView.fadeIn() + orderView.fadeIn() + if (contractTerms.products.size > 1) detailsButton.fadeIn() + totalLabelView.fadeIn() + totalView.fadeIn() + } + + override fun onImageClick(image: Bitmap) { + val f = ProductImageFragment.new(image) + f.show(parentFragmentManager, "image") + } + +} -- cgit v1.2.3