taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

WithdrawManager.kt (10597B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2020 Taler Systems S.A.
      4  *
      5  * GNU Taler is free software; you can redistribute it and/or modify it under the
      6  * terms of the GNU General Public License as published by the Free Software
      7  * Foundation; either version 3, or (at your option) any later version.
      8  *
      9  * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
     10  * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11  * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
     12  *
     13  * You should have received a copy of the GNU General Public License along with
     14  * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15  */
     16 
     17 package net.taler.cashier.withdraw
     18 
     19 import android.app.Application
     20 import android.graphics.Bitmap
     21 import android.os.CountDownTimer
     22 import android.util.Log
     23 import androidx.annotation.UiThread
     24 import androidx.lifecycle.LiveData
     25 import androidx.lifecycle.MutableLiveData
     26 import androidx.lifecycle.viewModelScope
     27 import kotlinx.coroutines.Dispatchers
     28 import kotlinx.coroutines.Job
     29 import kotlinx.coroutines.launch
     30 import net.taler.cashier.BalanceResult
     31 import net.taler.cashier.HttpHelper.makeJsonGetRequest
     32 import net.taler.cashier.HttpHelper.makeJsonPostRequest
     33 import net.taler.cashier.HttpJsonResult.Error
     34 import net.taler.cashier.HttpJsonResult.Success
     35 import net.taler.cashier.MainViewModel
     36 import net.taler.cashier.R
     37 import net.taler.common.Amount
     38 import net.taler.lib.android.QrCodeManager.makeQrCode
     39 import net.taler.lib.android.isOnline
     40 import org.json.JSONObject
     41 import java.util.concurrent.TimeUnit.SECONDS
     42 
     43 private val TAG = WithdrawManager::class.java.simpleName
     44 
     45 private val INTERVAL = SECONDS.toMillis(1)
     46 
     47 class WithdrawManager(
     48     private val app: Application,
     49     private val viewModel: MainViewModel,
     50 ) {
     51     private val scope
     52         get() = viewModel.viewModelScope
     53 
     54     private val config
     55         get() = viewModel.configManager.config
     56 
     57     private val currency: String?
     58         get() = viewModel.configManager.currency.value
     59 
     60     private var withdrawStatusCheck: Job? = null
     61 
     62     private val mWithdrawAmount = MutableLiveData<Amount?>()
     63     val withdrawAmount: LiveData<Amount?> = mWithdrawAmount
     64 
     65     private val mWithdrawResult = MutableLiveData<WithdrawResult?>()
     66     val withdrawResult: LiveData<WithdrawResult?> = mWithdrawResult
     67 
     68     private val mWithdrawStatus = MutableLiveData<WithdrawStatus?>()
     69     val withdrawStatus: LiveData<WithdrawStatus?> = mWithdrawStatus
     70 
     71     private val mLastTransaction = MutableLiveData<LastTransaction>()
     72     val lastTransaction: LiveData<LastTransaction> = mLastTransaction
     73 
     74     /**
     75      * Returns null if the given [amount] can't be compared to the balance
     76      * e.g. due to mismatching currency.
     77      */
     78     @UiThread
     79     fun hasSufficientBalance(amount: Amount): Boolean? {
     80         val balanceResult = viewModel.balance.value
     81         if (balanceResult !is BalanceResult.Success) return false
     82         return try {
     83             (balanceResult.amount.positive && amount <= (balanceResult.debitThreshold + balanceResult.amount.amount)) ||
     84                     (!balanceResult.amount.positive && amount <= (balanceResult.debitThreshold - balanceResult.amount.amount))
     85 
     86         } catch (e: IllegalStateException) {
     87             Log.e(TAG, "Error comparing amounts", e)
     88             null
     89         }
     90     }
     91 
     92     @UiThread
     93     fun withdraw(amount: Amount) {
     94         check(!amount.isZero()) { "Withdraw amount was 0" }
     95         check(currency != null) { "Currency is null" }
     96         mWithdrawResult.value = null
     97         mWithdrawAmount.value = amount
     98         scope.launch(Dispatchers.IO) {
     99             val url = "${config.bankUrl}/accounts/${config.username}/withdrawals"
    100             Log.d(TAG, "Starting withdrawal at $url")
    101             val map = mapOf("amount" to amount.toJSONString())
    102             val body = JSONObject(map)
    103             val result = when (val response = makeJsonPostRequest(url, body, config)) {
    104                 is Success -> {
    105                     try {
    106                         val talerUri = response.json.getString("taler_withdraw_uri")
    107                         val withdrawResult = WithdrawResult.Success(
    108                             id = response.json.getString("withdrawal_id"),
    109                             talerUri = talerUri,
    110                             qrCode = makeQrCode(talerUri)
    111                         )
    112                         withdrawResult
    113                     } catch (e: Exception) {
    114                         WithdrawResult.Error(e.toString())
    115                     }
    116                 }
    117                 is Error -> {
    118                     if (response.statusCode > 0 && app.isOnline()) {
    119                         val errorStr = app.getString(R.string.withdraw_error_fetch, response.msg)
    120                         WithdrawResult.Error(errorStr)
    121                     } else {
    122                         WithdrawResult.Offline
    123                     }
    124                 }
    125             }
    126             mWithdrawResult.postValue(result)
    127             if (result is WithdrawResult.Success) timer.start()
    128         }
    129     }
    130 
    131     private val timer: CountDownTimer = object : CountDownTimer(Long.MAX_VALUE, INTERVAL) {
    132         override fun onTick(millisUntilFinished: Long) {
    133             val result = withdrawResult.value
    134             if (result is WithdrawResult.Success) {
    135                 // check for active jobs and only do one at a time
    136                 val hasActiveCheck = withdrawStatusCheck?.isActive ?: false
    137                 if (!hasActiveCheck) {
    138                     withdrawStatusCheck = checkWithdrawStatus(result.id)
    139                 }
    140             } else {
    141                 cancel()
    142             }
    143         }
    144 
    145         override fun onFinish() {
    146             abort()
    147             val result = if (app.isOnline()) {
    148                 WithdrawStatus.Error(app.getString(R.string.withdraw_error_timeout))
    149             } else {
    150                 WithdrawStatus.Error(app.getString(R.string.withdraw_error_offline))
    151             }
    152             mWithdrawStatus.postValue(result)
    153             cancel()
    154         }
    155     }
    156 
    157     private fun checkWithdrawStatus(withdrawalId: String) = scope.launch(Dispatchers.IO) {
    158         val url =
    159             "${config.bankUrl}/withdrawals/${withdrawalId}"
    160         Log.d(TAG, "Checking withdraw status at $url")
    161         val response = makeJsonGetRequest(url, config)
    162         if (response !is Success) return@launch  // ignore errors and continue trying
    163         val oldStatus = mWithdrawStatus.value
    164         try {
    165             when(response.json.getString("status")) {
    166                 "selected" -> {
    167                     // only update status, if there's none, yet
    168                     // so we don't re-notify or overwrite newer status info
    169                     if (oldStatus == null) {
    170                         mWithdrawStatus.postValue(WithdrawStatus.SelectionDone(withdrawalId))
    171                     }
    172                 }
    173                 "aborted" -> {
    174                     cancelWithdrawStatusCheck()
    175                     mWithdrawStatus.postValue(WithdrawStatus.Aborted)
    176                 }
    177                 "confirmed" -> {
    178                     if (oldStatus !is WithdrawStatus.Success) {
    179                         cancelWithdrawStatusCheck()
    180                         mWithdrawStatus.postValue(WithdrawStatus.Success)
    181                         viewModel.getBalance()
    182                     }
    183                 }
    184             }
    185         } catch (e: Exception) {
    186             mWithdrawStatus.postValue(WithdrawStatus.Error(e.toString()))
    187         }
    188     }
    189 
    190     private fun cancelWithdrawStatusCheck() {
    191         timer.cancel()
    192         withdrawStatusCheck?.cancel()
    193     }
    194 
    195     /**
    196      * Aborts the last [withdrawResult], if it exists und there is no [withdrawStatus].
    197      * Otherwise this is a no-op.
    198      */
    199     @UiThread
    200     fun abort() {
    201         val result = withdrawResult.value
    202         val status = withdrawStatus.value
    203         if (result is WithdrawResult.Success && status == null) {
    204             cancelWithdrawStatusCheck()
    205             abort(result.id)
    206         } else {
    207             mWithdrawResult.value = null
    208             mWithdrawStatus.value = null
    209             mWithdrawAmount.value = null
    210         }
    211     }
    212 
    213     private fun abort(withdrawalId: String) = scope.launch(Dispatchers.IO) {
    214         val url =
    215             "${config.bankUrl}/accounts/${config.username}/withdrawals/${withdrawalId}/abort"
    216         Log.d(TAG, "Aborting withdrawal at $url")
    217         makeJsonPostRequest(url, JSONObject(), config)
    218         mWithdrawResult.postValue(null)
    219         mWithdrawStatus.postValue(null)
    220         mWithdrawAmount.postValue(null)
    221     }
    222 
    223     @UiThread
    224     fun confirm(withdrawalId: String) {
    225         mWithdrawStatus.value = WithdrawStatus.Confirming
    226         scope.launch(Dispatchers.IO) {
    227             val url =
    228                 "${config.bankUrl}/accounts/${config.username}/withdrawals/${withdrawalId}/confirm"
    229             Log.d(TAG, "Confirming withdrawal at $url")
    230             when (val response = makeJsonPostRequest(url, JSONObject(), config)) {
    231                 is Success -> {
    232                     // no-op still waiting for [timer] to confirm our confirmation
    233                 }
    234                 is Error -> {
    235                     Log.e(TAG, "Error confirming withdrawal. Status code: ${response.statusCode}")
    236                     val result = if (response.statusCode > 0 && app.isOnline()) {
    237                         WithdrawStatus.Error(response.msg)
    238                     } else {
    239                         WithdrawStatus.Error(app.getString(R.string.withdraw_error_offline))
    240                     }
    241                     mWithdrawStatus.postValue(result)
    242                 }
    243             }
    244         }
    245     }
    246 
    247     @UiThread
    248     fun completeTransaction() {
    249         mLastTransaction.value = LastTransaction(withdrawAmount.value!!, withdrawStatus.value!!)
    250         withdrawStatusCheck = null
    251         mWithdrawAmount.value = null
    252         mWithdrawResult.value = null
    253         mWithdrawStatus.value = null
    254     }
    255 
    256 }
    257 
    258 sealed class WithdrawResult {
    259     object InsufficientBalance : WithdrawResult()
    260     object Offline : WithdrawResult()
    261     class Error(val msg: String) : WithdrawResult()
    262     class Success(val id: String, val talerUri: String, val qrCode: Bitmap) : WithdrawResult()
    263 }
    264 
    265 sealed class WithdrawStatus {
    266     class Error(val msg: String) : WithdrawStatus()
    267     object Aborted : WithdrawStatus()
    268     class SelectionDone(val withdrawalId: String) : WithdrawStatus()
    269     object Confirming : WithdrawStatus()
    270     object Success : WithdrawStatus()
    271 }
    272 
    273 data class LastTransaction(
    274     val withdrawAmount: Amount,
    275     val withdrawStatus: WithdrawStatus,
    276 )