cashless2ecash

cashless2ecash: pay with cards for digital cash (experimental)
Log | Files | Refs | README

WithdrawalViewModel.kt (12981B)


      1 // This file is part of taler-cashless2ecash.
      2 // Copyright (C) 2024 Joel Häberli
      3 //
      4 // taler-cashless2ecash is free software: you can redistribute it and/or modify it
      5 // under the terms of the GNU Affero General Public License as published
      6 // by the Free Software Foundation, either version 3 of the License,
      7 // or (at your option) any later version.
      8 //
      9 // taler-cashless2ecash is distributed in the hope that it will be useful, but
     10 // WITHOUT ANY WARRANTY; without even the implied warranty of
     11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
     12 // Affero General Public License for more details.
     13 //
     14 // You should have received a copy of the GNU Affero General Public License
     15 // along with this program.  If not, see <http://www.gnu.org/licenses/>.
     16 //
     17 // SPDX-License-Identifier: AGPL3.0-or-later
     18 
     19 package ch.bfh.habej2.wallee_c2ec.withdrawal
     20 
     21 import android.app.Activity
     22 import android.os.Handler
     23 import android.os.Looper
     24 import android.widget.Toast
     25 import androidx.compose.runtime.Stable
     26 import androidx.lifecycle.ViewModel
     27 import androidx.lifecycle.viewModelScope
     28 import ch.bfh.habej2.wallee_c2ec.R
     29 import ch.bfh.habej2.wallee_c2ec.client.taler.TerminalClient
     30 import ch.bfh.habej2.wallee_c2ec.client.taler.TerminalClientImplementation
     31 import ch.bfh.habej2.wallee_c2ec.client.taler.config.TalerTerminalConfig
     32 import ch.bfh.habej2.wallee_c2ec.client.taler.encoding.CyptoUtils
     33 import ch.bfh.habej2.wallee_c2ec.client.taler.model.Amount
     34 import ch.bfh.habej2.wallee_c2ec.client.taler.model.AmountParserException
     35 import ch.bfh.habej2.wallee_c2ec.client.taler.model.TerminalWithdrawalSetup
     36 import ch.bfh.habej2.wallee_c2ec.withdrawal.WithdrawalViewModel.Companion.generateTransactionIdentifier
     37 import com.wallee.android.till.sdk.ApiClient
     38 import com.wallee.android.till.sdk.data.State
     39 import com.wallee.android.till.sdk.data.TransactionCompletionResponse
     40 import com.wallee.android.till.sdk.data.TransactionResponse
     41 import kotlinx.coroutines.flow.MutableStateFlow
     42 import kotlinx.coroutines.flow.StateFlow
     43 import kotlinx.coroutines.launch
     44 import java.io.Closeable
     45 import java.security.SecureRandom
     46 import java.util.Optional
     47 import java.util.UUID
     48 
     49 object TalerConstants {
     50     const val TALER_INTEGRATION = "/taler-integration"
     51 
     52     fun formatTalerUri(terminalsApiBasePath: String, encodedWopid: String) =
     53         "taler://withdraw/${stripLeadingProtocolIfPresent(terminalsApiBasePath)}/$encodedWopid"
     54 
     55     private fun stripLeadingProtocolIfPresent(terminalsApiBasePath: String) = terminalsApiBasePath
     56             .removePrefix("https://")
     57             .removePrefix("http://")
     58 }
     59 
     60 
     61 /**
     62  *            +--------------------------+
     63  *            | UNREADY_FOR_AUTHORIZATION |
     64  *            +--------------------------+
     65  *                      |
     66  *                      v
     67  *            +--------------------------+
     68  *            | READY_FOR_AUTHORIZATION  |
     69  *            +--------------------------+
     70  *                      |
     71  * 	   +-------------------------+
     72  * 	   | AUTHORIZATION_TRIGGERED |
     73  * 	   +-------------------------+
     74  *          	     |
     75  *          +-----------+-----------+
     76  *          |                       |
     77  *          v                       v
     78  * +-------------------+   +-------------------------+
     79  * | AUTHORIZED        | 	| AUTHORIZATION_FAILED    |
     80  * +-------------------+ 	+-------------------------+
     81  *          	|
     82  *          	v
     83  * 	    +----------------------+
     84  * 	    | COMPLETION_TRIGGERED |
     85  * 	    +----------------------+
     86  *          	        |
     87  *          +-----------+-----------+
     88  *          |                       |
     89  *          v                       v
     90  * +-------------------+   +-------------------------+
     91  * | COMPLETED        | 	| COMPLETION_FAILED      |
     92  * +-------------------+ 	+-------------------------+
     93  */
     94 
     95 val MAX_TRIES = 3
     96 
     97 enum class WithdrawalState {
     98     UNREADY_FOR_AUTHORIZATION,
     99     READY_FOR_AUTHORIZATION,
    100     E2EC_TIMEOUT,
    101     AUTHORIZATION_TRIGGERED,
    102     AUTHORIZED,
    103     AUTHORIZATION_FAILED,
    104     COMPLETION_TRIGGERED,
    105     COMPLETION_FAILED,
    106     COMPLETED,
    107 }
    108 
    109 @Stable
    110 data class Withdrawal(
    111     val requestUid: String = UUID.randomUUID().toString(),
    112     val withdrawalState: WithdrawalState = WithdrawalState.UNREADY_FOR_AUTHORIZATION,
    113     val setupWithdrawalRetries: Int = 0,
    114     val terminalsApiBasePath: String = "",
    115     val encodedWopid: String = "",
    116     val amount: Amount = Amount("", 0, 0),
    117     val amountStr: String = "",
    118     val amountError: String = "",
    119     val currency: String = "",
    120     val withdrawalFees: Amount = Amount("", 0, 0),
    121     val transaction: TransactionResponse? = null,
    122     val transactionCompletion: TransactionCompletionResponse? = null,
    123     val transactionId: String = generateTransactionIdentifier()
    124 )
    125 
    126 class WithdrawalViewModel(
    127     private val walleeClient: ApiClient,
    128     vararg closeables: Closeable
    129 ) : ViewModel(*closeables) {
    130 
    131     private var exchangeSelected = false
    132     private var terminalClient: TerminalClient? = null
    133     private val _uiState = MutableStateFlow(Withdrawal())
    134     val uiState: StateFlow<Withdrawal> = _uiState
    135 
    136     companion object {
    137         fun generateTransactionIdentifier(): String {
    138             val wopid = ByteArray(32)
    139             SecureRandom().nextBytes(wopid)
    140             return CyptoUtils.encodeCrock(wopid)
    141         }
    142     }
    143 
    144     fun chooseExchange(cfg: TalerTerminalConfig, activity: Activity) {
    145 
    146         if (exchangeSelected) {
    147             println("exchange cannot be changed after selection.")
    148             return
    149         }
    150 
    151         terminalClient = TerminalClientImplementation(cfg)
    152         //terminalClient = TerminalClientMock()
    153         _uiState.value = Withdrawal() // reset withdrawal operation
    154 
    155         terminalClient!!.terminalsConfig {
    156             println("terminal config request result present: ${it.isPresent}")
    157             if (!it.isPresent) {
    158                 Handler(Looper.getMainLooper()).post {Toast.makeText(activity.baseContext, activity.getText(R.string.not_connected), Toast.LENGTH_SHORT).show()}
    159                 activity.finish()
    160             } else {
    161                 _uiState.value = _uiState.value.copy(
    162                     terminalsApiBasePath = "${cfg.terminalApiBaseUrl}${TalerConstants.TALER_INTEGRATION}"
    163                 )
    164                 this@WithdrawalViewModel.updateCurrency(it.get().currency)
    165                 if (!this@WithdrawalViewModel.updateWithdrawalFees(it.get().exchangeFees)) {
    166                     Handler(Looper.getMainLooper()).post {Toast.makeText(activity.baseContext, activity.getText(R.string.not_fees), Toast.LENGTH_SHORT).show()}
    167                     activity.finish()
    168                 }
    169             }
    170             exchangeSelected = true
    171         }
    172     }
    173 
    174     fun setupWithdrawal(
    175         activity: Activity
    176     ) {
    177 
    178         val setupReq = TerminalWithdrawalSetup(
    179             _uiState.value.requestUid,
    180             TerminalClient.FormatAmount(_uiState.value.amount)
    181         )
    182 
    183         viewModelScope.launch {
    184             terminalClient!!.setupWithdrawal(setupReq) {
    185                 if (!it.isPresent) {
    186                     withdrawalOperationFailed(activity)
    187                 }
    188                 val wopid = it.get().withdrawalId
    189                 println("retrieved WOPID from c2ec: $wopid")
    190                 updateState { copy(encodedWopid = wopid) }
    191             }
    192         }
    193     }
    194 
    195     private fun updateState(update: Withdrawal.() -> Withdrawal) {
    196         val newState = _uiState.value.update()
    197         if (newState != _uiState.value) {
    198             _uiState.value = newState
    199         }
    200     }
    201 
    202 
    203     fun validateInput(amount: String): Boolean {
    204 
    205         //println("validating amount input: $amount")
    206         val validAmount = parseAmount(amount)
    207         if (validAmount.isPresent) {
    208             updateState { copy(amountError = "") }
    209             updateState { copy(amountStr = amount) }
    210             updateState { copy(amount = validAmount.get()) }
    211             return true
    212         } else {
    213             updateState { copy(amountError = "invalid amount (format: X[.X])")}
    214             return false
    215         }
    216     }
    217 
    218     private fun updateCurrency(currency: String) {
    219         _uiState.value = _uiState.value.copy(
    220             currency = currency,
    221             amount = Amount(currency, _uiState.value.amount.value, _uiState.value.amount.fraction)
    222         )
    223     }
    224 
    225     private fun updateWithdrawalFees(exchangeFees: String): Boolean {
    226 
    227         val optRes = this.parseAmount(exchangeFees)
    228         if (!optRes.isPresent) {
    229             return false
    230         }
    231         updateState { copy(withdrawalFees = optRes.get()) }
    232         return true
    233     }
    234 
    235     fun setTransactionResponse(
    236         response: TransactionResponse
    237     ) {
    238         updateState { copy(transaction = response) }
    239     }
    240 
    241 
    242     fun setState(
    243         state: WithdrawalState
    244     ) {
    245         if (_uiState.value.withdrawalState != state) {
    246             println("setting state to $state")
    247             updateState { copy(withdrawalState = state) }
    248         } else {
    249             println("state already set to $state")
    250         }
    251     }
    252 
    253     fun incrementCounter() {
    254         _uiState.value = uiState.value.copy(
    255             setupWithdrawalRetries = _uiState.value.setupWithdrawalRetries + 1
    256         )
    257     }
    258 
    259     fun setAuthorizing() {
    260         setState(WithdrawalState.AUTHORIZATION_TRIGGERED)
    261     }
    262 
    263     fun setCompleting() {
    264         setState(WithdrawalState.COMPLETION_TRIGGERED)
    265     }
    266 
    267     fun updateWalleeTransactionReply(response: TransactionResponse) {
    268         println("updating wallee transaction: $response")
    269         setTransactionResponse(response)
    270 
    271         if (response.state.name == "SUCCESSFUL")
    272             setState(WithdrawalState.AUTHORIZED)
    273         else
    274             setState(WithdrawalState.AUTHORIZATION_FAILED)
    275     }
    276 
    277     fun updateWalleeTransactionCompletion(
    278         completion: TransactionCompletionResponse
    279     ) {
    280 
    281         if (completion.state == State.FAILED) {
    282             println("completion of the transaction failed... aborting")
    283             setState(WithdrawalState.COMPLETION_FAILED)
    284             return
    285         }
    286 
    287         if (ManageActivity.INSTANT_SETTLEMENT_ENABLED.value) {
    288             walleeClient.executeFinalBalance()
    289         }
    290 
    291         updateState { copy(withdrawalState = WithdrawalState.COMPLETED) }
    292         updateState { copy(transactionCompletion = completion) }
    293     }
    294 
    295     fun setAuthorizationReadyOrRetry() {
    296         println("setting authorization ready or retry")
    297         if (_uiState.value.setupWithdrawalRetries < MAX_TRIES) {
    298             terminalClient!!.retrieveWithdrawalStatus(uiState.value.encodedWopid, 10000) {
    299                 if (!it.isPresent) {
    300                     if (_uiState.value.setupWithdrawalRetries > MAX_TRIES) {
    301                         println("setting e2ec timeout")
    302                         terminalClient!!.shutdownDispatcher()
    303                         setState(WithdrawalState.E2EC_TIMEOUT)
    304                     } else {
    305                         println("setting retry because no status was present")
    306                         incrementCounter()
    307                     }
    308                 } else if (_uiState.value.withdrawalState == WithdrawalState.E2EC_TIMEOUT) {
    309                     println("Already timed out, ignoring status")
    310                     terminalClient!!.shutdownDispatcher()
    311                 } else if (_uiState.value.withdrawalState == WithdrawalState.AUTHORIZATION_TRIGGERED) {
    312                     println("authorization triggered, waiting for completion")
    313                 } else {
    314                     println("withdrawal status: ${it.get().status}")
    315                     setState(WithdrawalState.READY_FOR_AUTHORIZATION)
    316                 }
    317             }
    318         }
    319     }
    320 
    321     fun withdrawalOperationFailed(activity: Activity) {
    322         println("withdrawal operation failed called")
    323         Handler(Looper.getMainLooper()).post {
    324             Toast.makeText(activity.baseContext, activity.getText(
    325                 R.string.aborted), Toast.LENGTH_SHORT).show()}
    326         activity.finish()
    327     }
    328 
    329     fun validAmount(inp: String) = Regex("\\d+(\\.\\d+)?").matches(inp)
    330 
    331     /**
    332      * Format expected [CURRENCY:]X[.X], X an integer
    333      */
    334     private fun parseAmount(inp: String): Optional<Amount> {
    335 
    336         return if (inp.contains(":")) {
    337             try {
    338                 Optional.of(Amount.fromJSONString(inp))
    339             } catch (ex: AmountParserException) {
    340                 println("failed parsing amount with currency prefix: $ex")
    341                 Optional.empty()
    342             }
    343         } else {
    344             try {
    345                 Optional.of(Amount.fromString(_uiState.value.currency, inp))
    346             } catch (ex: AmountParserException) {
    347                 println("failed parsing plain amount: $ex")
    348                 Optional.empty()
    349             }
    350         }
    351     }
    352 
    353     fun resetAmountStr() {
    354         println("resetting amountStr. was ${_uiState.value.amountStr}")
    355         _uiState.value = _uiState.value.copy(
    356             amount = Amount(
    357                 "",
    358                 0,
    359                 0
    360             ),
    361             amountError = "",
    362             amountStr = "",
    363         )
    364     }
    365 }