taler-android

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

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 }