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 }