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 )