PeerManager.kt (17068B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2022 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.wallet.peer 18 19 import android.util.Log 20 import androidx.annotation.UiThread 21 import kotlinx.coroutines.CoroutineScope 22 import kotlinx.coroutines.Dispatchers 23 import kotlinx.coroutines.flow.MutableStateFlow 24 import kotlinx.coroutines.flow.StateFlow 25 import kotlinx.coroutines.flow.update 26 import kotlinx.coroutines.launch 27 import kotlinx.serialization.Serializable 28 import kotlinx.serialization.json.Json 29 import net.taler.common.Amount 30 import net.taler.common.Timestamp 31 import net.taler.wallet.main.TAG 32 import net.taler.wallet.backend.BackendManager 33 import net.taler.wallet.backend.TalerErrorInfo 34 import net.taler.wallet.backend.WalletBackendApi 35 import net.taler.wallet.balances.ScopeInfo 36 import net.taler.wallet.cleanExchange 37 import net.taler.wallet.exchanges.ExchangeItem 38 import net.taler.wallet.exchanges.ExchangeManager 39 import net.taler.wallet.payment.InsufficientBalanceHint 40 import org.json.JSONObject 41 import java.util.concurrent.TimeUnit.HOURS 42 import net.taler.wallet.peer.CheckPeerPushDebitResponse.* 43 44 const val MAX_LENGTH_SUBJECT = 100 45 val DEFAULT_EXPIRY = ExpirationOption.DAYS_1 46 47 sealed class CheckFeeResult { 48 abstract val maxDepositAmountEffective: Amount? 49 abstract val maxDepositAmountRaw: Amount? 50 51 data class None( 52 override val maxDepositAmountEffective: Amount? = null, 53 override val maxDepositAmountRaw: Amount? = null, 54 ): CheckFeeResult() 55 56 data class InsufficientBalance( 57 val maxAmountEffective: Amount?, 58 val maxAmountRaw: Amount?, 59 val causeHint: InsufficientBalanceHint? = null, 60 override val maxDepositAmountEffective: Amount? = null, 61 override val maxDepositAmountRaw: Amount? = null, 62 ): CheckFeeResult() 63 64 data class Success( 65 val amountRaw: Amount, 66 val amountEffective: Amount, 67 val exchangeBaseUrl: String, 68 override val maxDepositAmountEffective: Amount? = null, 69 override val maxDepositAmountRaw: Amount? = null, 70 ): CheckFeeResult() 71 } 72 73 @Serializable 74 data class GetMaxPeerPushDebitAmountResponse( 75 val effectiveAmount: Amount, 76 val rawAmount: Amount, 77 val exchangeBaseUrl: String? = null, 78 ) 79 80 class PeerManager( 81 private val api: WalletBackendApi, 82 private val exchangeManager: ExchangeManager, 83 private val scope: CoroutineScope, 84 ) { 85 86 private val _outgoingPullState = MutableStateFlow<OutgoingState>(OutgoingIntro) 87 val pullState: StateFlow<OutgoingState> = _outgoingPullState 88 89 private val _outgoingPushState = MutableStateFlow<OutgoingState>(OutgoingIntro) 90 val pushState: StateFlow<OutgoingState> = _outgoingPushState 91 92 private val _incomingPullState = MutableStateFlow<IncomingState>(IncomingChecking) 93 val incomingPullState: StateFlow<IncomingState> = _incomingPullState 94 95 private val _incomingPushState = MutableStateFlow<IncomingState>(IncomingChecking) 96 val incomingPushState: StateFlow<IncomingState> = _incomingPushState 97 98 suspend fun checkPeerPullCredit( 99 amount: Amount, 100 scopeInfo: ScopeInfo, 101 loading: Boolean = false, 102 ): CheckPeerPullCreditResult? { 103 var response: CheckPeerPullCreditResult? = null 104 val exchangeItem = exchangeManager.findExchange(scopeInfo) ?: return null 105 106 if (loading) { 107 _outgoingPullState.value = OutgoingChecking 108 } 109 110 if (!exchangeItem.tosStatus.isAccepted()) { 111 _outgoingPullState.value = OutgoingIntro 112 return CheckPeerPullCreditResult( 113 tosStatus = exchangeItem.tosStatus, 114 exchangeBaseUrl = exchangeItem.exchangeBaseUrl, 115 ) 116 } else if (amount.isZero()) { 117 _outgoingPullState.value = OutgoingIntro 118 return CheckPeerPullCreditResult( 119 tosStatus = exchangeItem.tosStatus, 120 exchangeBaseUrl = exchangeItem.exchangeBaseUrl, 121 amountRaw = amount, 122 amountEffective = amount, 123 ) 124 } 125 126 api.request("checkPeerPullCredit", CheckPeerPullCreditResponse.serializer()) { 127 put("restrictScope", JSONObject(BackendManager.json.encodeToString(scopeInfo))) 128 put("amount", amount.toJSONString()) 129 }.onSuccess { 130 response = CheckPeerPullCreditResult( 131 amountEffective = it.amountEffective, 132 amountRaw = it.amountRaw, 133 exchangeBaseUrl = it.exchangeBaseUrl, 134 tosStatus = exchangeItem.tosStatus, 135 ) 136 }.onError { error -> 137 Log.e(TAG, "got checkPeerPullCredit error result $error") 138 } 139 140 if (loading) { 141 _outgoingPullState.value = OutgoingIntro 142 } 143 144 return response 145 } 146 147 fun initiatePeerPullCredit(amount: Amount, summary: String, expirationHours: Long, exchangeBaseUrl: String) { 148 _outgoingPullState.value = OutgoingCreating 149 scope.launch(Dispatchers.IO) { 150 val expiry = Timestamp.fromMillis(System.currentTimeMillis() + HOURS.toMillis(expirationHours)) 151 api.request("initiatePeerPullCredit", InitiatePeerPullPaymentResponse.serializer()) { 152 put("exchangeBaseUrl", exchangeBaseUrl) 153 put("partialContractTerms", JSONObject().apply { 154 put("amount", amount.toJSONString()) 155 put("summary", summary) 156 put("purse_expiration", JSONObject(Json.encodeToString(expiry))) 157 }) 158 }.onSuccess { 159 _outgoingPullState.value = OutgoingResponse(it.transactionId) 160 }.onError { error -> 161 Log.e(TAG, "got initiatePeerPullCredit error result $error") 162 _outgoingPullState.value = OutgoingError(error) 163 } 164 } 165 } 166 167 fun resetPullPayment() { 168 _outgoingPullState.value = OutgoingIntro 169 } 170 171 suspend fun checkPeerPushFees( 172 amount: Amount, 173 exchangeBaseUrl: String? = null, 174 restrictScope: ScopeInfo? = null, 175 ): CheckFeeResult { 176 val max = getMaxPeerPushDebitAmount(amount.currency, exchangeBaseUrl, restrictScope = restrictScope) 177 var response: CheckFeeResult = CheckFeeResult.None( 178 maxDepositAmountEffective = max?.effectiveAmount, 179 maxDepositAmountRaw = max?.rawAmount, 180 ) 181 182 if (amount.isZero() && exchangeBaseUrl != null) { 183 return CheckFeeResult.Success( 184 amountRaw = amount, 185 amountEffective = amount, 186 maxDepositAmountEffective = max?.effectiveAmount, 187 maxDepositAmountRaw = max?.rawAmount, 188 exchangeBaseUrl = exchangeBaseUrl, 189 ) 190 } 191 192 api.request("checkPeerPushDebitV2", CheckPeerPushDebitResponse.serializer()) { 193 exchangeBaseUrl?.let { put("exchangeBaseUrl", it) } 194 restrictScope?.let { put("restrictScope", JSONObject(BackendManager.json.encodeToString(it))) } 195 put("amount", amount.toJSONString()) 196 }.onSuccess { res -> 197 response = when (val r = res) { 198 is CheckPeerPushDebitOkResponse -> CheckFeeResult.Success( 199 amountRaw = r.amountRaw, 200 amountEffective = r.amountEffective, 201 maxDepositAmountEffective = max?.effectiveAmount, 202 maxDepositAmountRaw = max?.rawAmount, 203 exchangeBaseUrl = r.exchangeBaseUrl, 204 ) 205 206 is CheckPeerPushDebitInsufficientBalanceResponse -> CheckFeeResult.InsufficientBalance( 207 maxAmountEffective = r.insufficientBalanceDetails.maxEffectiveSpendAmount, 208 maxAmountRaw = r.insufficientBalanceDetails.balanceAvailable, 209 maxDepositAmountEffective = max?.effectiveAmount, 210 maxDepositAmountRaw = max?.rawAmount, 211 causeHint = r.insufficientBalanceDetails.causeHint, 212 ) 213 } 214 }.onError { error -> 215 Log.e(TAG, "got checkPeerPushDebit error result $error") 216 } 217 218 return response 219 } 220 221 private suspend fun getMaxPeerPushDebitAmount( 222 currency: String, 223 exchangeBaseUrl: String? = null, 224 restrictScope: ScopeInfo? = null, 225 ): GetMaxPeerPushDebitAmountResponse? { 226 var response: GetMaxPeerPushDebitAmountResponse? = null 227 api.request("getMaxPeerPushDebitAmount", GetMaxPeerPushDebitAmountResponse.serializer()) { 228 exchangeBaseUrl?.let { put("exchangeBaseUrl", it) } 229 restrictScope?.let { put("restrictScope", JSONObject(BackendManager.json.encodeToString(it))) } 230 put("currency", currency) 231 }.onError { error -> 232 Log.e(TAG, "got getMaxPeerPushDebitAmount error result $error") 233 }.onSuccess { 234 response = it 235 } 236 237 return response 238 } 239 240 fun initiatePeerPushDebit( 241 amount: Amount, 242 summary: String, 243 expirationHours: Long, 244 restrictScope: ScopeInfo? = null, 245 ) { 246 _outgoingPushState.value = OutgoingCreating 247 scope.launch(Dispatchers.IO) { 248 val expiry = Timestamp.fromMillis(System.currentTimeMillis() + HOURS.toMillis(expirationHours)) 249 api.request("initiatePeerPushDebit", InitiatePeerPushDebitResponse.serializer()) { 250 restrictScope?.let { put("restrictScope", JSONObject(BackendManager.json.encodeToString(it))) } 251 put("amount", amount.toJSONString()) 252 put("partialContractTerms", JSONObject().apply { 253 put("amount", amount.toJSONString()) 254 put("summary", summary) 255 put("purse_expiration", JSONObject(Json.encodeToString(expiry))) 256 }) 257 }.onSuccess { response -> 258 _outgoingPushState.value = OutgoingResponse(response.transactionId) 259 }.onError { error -> 260 Log.e(TAG, "got initiatePeerPushDebit error result $error") 261 _outgoingPushState.value = OutgoingError(error) 262 } 263 } 264 } 265 266 fun resetPushPayment() { 267 _outgoingPushState.value = OutgoingIntro 268 } 269 270 fun preparePeerPullDebit( 271 talerUri: String? = null, 272 transactionId: String? = null, 273 ) { 274 _incomingPullState.value = IncomingChecking 275 scope.launch(Dispatchers.IO) { 276 api.request("preparePeerPullDebit", PreparePeerPullDebitResponse.serializer()) { 277 talerUri?.let { put("talerUri", talerUri) } 278 transactionId?.let { put("transactionId", transactionId) } 279 this 280 }.onSuccess { response -> 281 _incomingPullState.value = IncomingTerms( 282 amountRaw = response.amountRaw, 283 amountEffective = response.amountEffective, 284 contractTerms = response.contractTerms, 285 id = response.transactionId, 286 ) 287 }.onError { error -> 288 Log.e(TAG, "got preparePeerPullDebit error result $error") 289 _incomingPullState.value = IncomingError(error) 290 } 291 } 292 } 293 294 fun confirmPeerPullDebit(terms: IncomingTerms) { 295 _incomingPullState.value = IncomingAccepting(terms) 296 scope.launch(Dispatchers.IO) { 297 api.request<Unit>("confirmPeerPullDebit") { 298 put("transactionId", terms.id) 299 }.onSuccess { 300 _incomingPullState.value = IncomingAccepted(terms.id) 301 }.onError { error -> 302 Log.e(TAG, "got confirmPeerPullDebit error result $error") 303 _incomingPullState.value = IncomingError(error) 304 } 305 } 306 } 307 308 fun preparePeerPushCredit( 309 talerUri: String? = null, 310 transactionId: String? = null, 311 ) { 312 _incomingPushState.value = IncomingChecking 313 scope.launch(Dispatchers.IO) a@ { 314 api.request("preparePeerPushCredit", PreparePeerPushCreditResponse.serializer()) { 315 talerUri?.let { put("talerUri", talerUri) } 316 transactionId?.let { put("transactionId", transactionId) } 317 this 318 }.onSuccess { response -> 319 scope.launch(Dispatchers.IO) b@ { 320 val exchange = exchangeManager.findExchangeByUrl(response.exchangeBaseUrl) 321 322 if (exchange == null) { 323 Log.d(TAG, "exchange entry for ${response.exchangeBaseUrl} was not found") 324 _incomingPushState.value = IncomingError( 325 TalerErrorInfo.makeCustomError( // TODO: localize error 326 "No provider with URL ${cleanExchange(response.exchangeBaseUrl)} was found in the wallet", 327 ) 328 ) 329 return@b 330 } 331 332 _incomingPushState.value = if (exchange.tosStatus.isAccepted()) { 333 IncomingTerms( 334 amountRaw = response.amountRaw, 335 amountEffective = response.amountEffective, 336 contractTerms = response.contractTerms, 337 id = response.transactionId, 338 ) 339 } else { 340 IncomingTosReview( 341 amountRaw = response.amountRaw, 342 amountEffective = response.amountEffective, 343 contractTerms = response.contractTerms, 344 exchangeBaseUrl = response.exchangeBaseUrl, 345 id = response.transactionId, 346 ) 347 } 348 } 349 }.onError { error -> 350 Log.e(TAG, "got preparePeerPushCredit error result $error") 351 _incomingPushState.value = IncomingError(error) 352 } 353 } 354 } 355 356 fun confirmPeerPushCredit(terms: IncomingTerms) { 357 _incomingPushState.value = IncomingAccepting(terms) 358 scope.launch(Dispatchers.IO) { 359 api.request<Unit>("confirmPeerPushCredit") { 360 put("transactionId", terms.id) 361 }.onSuccess { 362 _incomingPushState.value = IncomingAccepted(terms.id) 363 }.onError { error -> 364 Log.e(TAG, "got confirmPeerPushCredit error result $error") 365 _incomingPushState.value = IncomingError(error) 366 } 367 } 368 } 369 370 @UiThread 371 fun refreshPeerPushCreditTos(exchanges: List<ExchangeItem>) = scope.launch { 372 _incomingPushState.update { state -> 373 var newState = state 374 if (state is IncomingTosReview) { 375 exchanges.find { it.exchangeBaseUrl == state.exchangeBaseUrl }?.let { exchange -> 376 if (exchange.tosStatus.isAccepted()) { 377 newState = IncomingTerms( 378 amountRaw = state.amountRaw, 379 amountEffective = state.amountEffective, 380 contractTerms = state.contractTerms, 381 id = state.id, 382 ) 383 } 384 } ?: run { 385 Log.d(TAG, "could not refresh ToS status, exchange ${state.exchangeBaseUrl} was not found") 386 } 387 } 388 newState 389 } 390 } 391 392 @UiThread 393 fun refreshPeerPullCreditTos(exchanges: List<ExchangeItem>) = scope.launch { 394 _outgoingPullState.update { state -> 395 var newState = state 396 if (state is OutgoingChecked) { 397 exchanges.find { it.exchangeBaseUrl == state.exchangeBaseUrl }?.let { exchange -> 398 if (exchange.tosStatus.isAccepted()) { 399 newState = OutgoingChecked( 400 amountRaw = state.amountRaw, 401 amountEffective = state.amountEffective, 402 exchangeBaseUrl = state.exchangeBaseUrl, 403 tosStatus = exchange.tosStatus, 404 ) 405 } 406 } ?: run { 407 Log.d(TAG, "could not refresh ToS status, exchange ${state.exchangeBaseUrl} was not found") 408 } 409 } 410 newState 411 } 412 } 413 }