Transactions.kt (24797B)
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.wallet.transactions 18 19 import android.net.Uri 20 import android.util.Log 21 import androidx.annotation.DrawableRes 22 import androidx.annotation.StringRes 23 import androidx.compose.runtime.Composable 24 import androidx.compose.ui.res.stringResource 25 import androidx.core.net.toUri 26 import kotlinx.serialization.KSerializer 27 import kotlinx.serialization.SerialName 28 import kotlinx.serialization.Serializable 29 import kotlinx.serialization.SerializationException 30 import kotlinx.serialization.Transient 31 import kotlinx.serialization.builtins.ListSerializer 32 import kotlinx.serialization.builtins.MapSerializer 33 import kotlinx.serialization.builtins.serializer 34 import kotlinx.serialization.descriptors.SerialDescriptor 35 import kotlinx.serialization.encoding.Decoder 36 import kotlinx.serialization.encoding.Encoder 37 import kotlinx.serialization.json.JsonElement 38 import net.taler.common.Amount 39 import net.taler.common.Bech32 40 import net.taler.common.ContractProduct 41 import net.taler.common.ContractTerms 42 import net.taler.common.Timestamp 43 import net.taler.wallet.R 44 import net.taler.wallet.main.TAG 45 import net.taler.wallet.backend.TalerErrorCode 46 import net.taler.wallet.backend.TalerErrorInfo 47 import net.taler.common.CurrencySpecification 48 import net.taler.common.Merchant 49 import net.taler.common.RelativeTime 50 import net.taler.wallet.WalletDestination 51 import net.taler.wallet.accounts.PaytoUriCyclos 52 import net.taler.wallet.accounts.PaytoUriIban 53 import net.taler.wallet.accounts.PaytoUriTalerBank 54 import net.taler.wallet.balances.ScopeInfo 55 import net.taler.wallet.refund.RefundPaymentInfo 56 import net.taler.wallet.transactions.TransactionMajorState.Done 57 import net.taler.wallet.transactions.TransactionMajorState.None 58 import net.taler.wallet.transactions.TransactionMajorState.Pending 59 import net.taler.wallet.transactions.WithdrawalDetails.ManualTransfer 60 import net.taler.wallet.transactions.WithdrawalDetails.TalerBankIntegrationApi 61 import net.taler.wallet.withdraw.TransferData 62 import java.util.UUID 63 64 @Serializable 65 data class Transactions( 66 @Serializable(with = TransactionListSerializer::class) 67 val transactions: List<Transaction>, 68 ) 69 70 class TransactionListSerializer : KSerializer<List<Transaction>> { 71 private val serializer = ListSerializer(TransactionSerializer()) 72 override val descriptor: SerialDescriptor = serializer.descriptor 73 74 override fun deserialize(decoder: Decoder): List<Transaction> { 75 return decoder.decodeSerializableValue(serializer) 76 } 77 78 override fun serialize(encoder: Encoder, value: List<Transaction>) { 79 throw NotImplementedError() 80 } 81 } 82 83 class TransactionSerializer : KSerializer<Transaction> { 84 85 private val serializer = Transaction.serializer() 86 override val descriptor: SerialDescriptor = serializer.descriptor 87 private val jsonSerializer = MapSerializer(String.serializer(), JsonElement.serializer()) 88 89 override fun deserialize(decoder: Decoder): Transaction { 90 return try { 91 decoder.decodeSerializableValue(serializer) 92 } catch (e: SerializationException) { 93 Log.e(TAG, "Error deserializing transaction.", e) 94 DummyTransaction( 95 transactionId = UUID.randomUUID().toString(), 96 timestamp = Timestamp.now(), 97 error = TalerErrorInfo( 98 code = TalerErrorCode.UNKNOWN, 99 message = e.message, 100 extra = decoder.decodeSerializableValue(jsonSerializer) 101 ), 102 ) 103 } 104 } 105 106 override fun serialize(encoder: Encoder, value: Transaction) { 107 throw NotImplementedError() 108 } 109 } 110 111 @Serializable 112 sealed class Transaction { 113 abstract val transactionId: String 114 abstract val timestamp: Timestamp 115 abstract val txState: TransactionState 116 abstract val txActions: List<TransactionAction> 117 abstract val error: TalerErrorInfo? 118 abstract val amountRaw: Amount 119 abstract val amountEffective: Amount 120 abstract val scopes: List<ScopeInfo> 121 122 @get:DrawableRes 123 abstract val icon: Int 124 125 abstract val detailPageNav: WalletDestination 126 127 abstract val amountType: AmountType 128 129 @Composable 130 abstract fun getTitle(): String 131 132 @get:StringRes 133 abstract val generalTitleRes: Int 134 } 135 136 @Serializable 137 enum class TransactionAction { 138 // Common States 139 @SerialName("delete") 140 Delete, 141 142 @SerialName("suspend") 143 Suspend, 144 145 @SerialName("resume") 146 Resume, 147 148 @SerialName("abort") 149 Abort, 150 151 @SerialName("fail") 152 Fail, 153 154 @SerialName("retry") 155 Retry, 156 } 157 158 sealed class AmountType { 159 object Positive : AmountType() 160 object Negative : AmountType() 161 object Neutral : AmountType() 162 } 163 164 @Serializable 165 @SerialName("withdrawal") 166 class TransactionWithdrawal( 167 override val transactionId: String, 168 override val timestamp: Timestamp, 169 override val txState: TransactionState, 170 override val txActions: List<TransactionAction>, 171 val kycUrl: String? = null, 172 val exchangeBaseUrl: String? = null, 173 val withdrawalDetails: WithdrawalDetails, 174 override val error: TalerErrorInfo? = null, 175 override val amountRaw: Amount, 176 override val amountEffective: Amount, 177 override val scopes: List<ScopeInfo>, 178 ) : Transaction() { 179 override val icon = R.drawable.transaction_withdrawal 180 181 override val detailPageNav = WalletDestination.TransactionWithdrawal 182 183 @Transient 184 override val amountType = AmountType.Positive 185 @Composable 186 override fun getTitle() = stringResource(R.string.withdraw_title) 187 override val generalTitleRes = R.string.withdraw_title 188 val confirmed: Boolean 189 get() = txState.major != Pending && ( 190 (withdrawalDetails is TalerBankIntegrationApi && withdrawalDetails.confirmed) || 191 withdrawalDetails is ManualTransfer 192 ) 193 } 194 195 @Serializable 196 sealed class WithdrawalDetails { 197 @Serializable 198 @SerialName("manual-transfer") 199 class ManualTransfer( 200 val exchangeCreditAccountDetails: List<WithdrawalExchangeAccountDetails>? = null, 201 val reserveClosingDelay: RelativeTime, 202 ) : WithdrawalDetails() 203 204 @Serializable 205 @SerialName("taler-bank-integration-api") 206 class TalerBankIntegrationApi( 207 /** 208 * Set to true if the bank has confirmed the withdrawal, false if not. 209 * An unconfirmed withdrawal usually requires user-input 210 * and should be highlighted in the UI. 211 * See also bankConfirmationUrl below. 212 */ 213 val confirmed: Boolean, 214 215 /** 216 * If the withdrawal is unconfirmed, this can include a URL for user-initiated confirmation. 217 */ 218 val bankConfirmationUrl: String? = null, 219 ) : WithdrawalDetails() 220 } 221 222 @Serializable 223 data class WithdrawalExchangeAccountDetails ( 224 /** 225 * Payto URI to credit the exchange. 226 * 227 * Depending on whether the (manual!) withdrawal is accepted or just 228 * being checked, this already includes the subject with the 229 * reserve public key. 230 */ 231 val paytoUri: String, 232 233 /** 234 * Status that indicates whether the account can be used 235 * by the user to send funds for a withdrawal. 236 * 237 * ok: account should be shown to the user 238 * error: account should not be shown to the user, UIs might render the error (in conversionError), 239 * especially in dev mode. 240 */ 241 val status: Status, 242 243 /** 244 * Transfer amount. Might be in a different currency than the requested 245 * amount for withdrawal. 246 * 247 * Redundant with the amount in paytoUri, just included to avoid parsing. 248 */ 249 val transferAmount: Amount? = null, 250 251 /** 252 * Currency specification for the external currency. 253 * 254 * Only included if this account requires a currency conversion. 255 */ 256 val currencySpecification: CurrencySpecification? = null, 257 258 /** 259 * Further restrictions for sending money to the 260 * exchange. 261 */ 262 val creditRestrictions: List<AccountRestriction>? = null, 263 264 /** 265 * Label given to the account or the account's bank by the exchange. 266 */ 267 val bankLabel: String? = null, 268 269 val priority: Int? = null, 270 ) { 271 @Serializable 272 enum class Status { 273 @SerialName("ok") 274 Ok, 275 276 @SerialName("error") 277 Error; 278 } 279 280 fun getTransferDetails( 281 amountRaw: Amount, 282 amountEffective: Amount, 283 ): TransferData? { 284 val uri = paytoUri.trim().toUri() 285 val transferAmount = (transferAmount 286 ?: uri.getQueryParameter("amount") 287 ?.let { Amount.fromJSONString(it) } 288 ?: amountEffective).withSpec(currencySpecification) 289 return if ("bitcoin".equals(uri.authority, true)) { 290 // FIXME: use parsing logic from PaytoUriBitcoin.fromString() 291 val msg = uri.getQueryParameter("message").orEmpty() 292 val reg = "\\b([A-Z0-9]{52})\\b".toRegex().find(msg) 293 val reserve = reg?.value 294 ?: uri.getQueryParameter("subject") 295 ?: return null 296 val segwitAddresses = 297 Bech32.generateFakeSegwitAddress(reserve, uri.pathSegments.first()) 298 TransferData.Bitcoin( 299 account = uri.lastPathSegment ?: return null, 300 segwitAddresses = segwitAddresses, 301 subject = reserve, 302 amountRaw = amountRaw, 303 amountEffective = amountEffective, 304 transferAmount = transferAmount, 305 withdrawalAccount = copy(paytoUri = uri.toString()), 306 ) 307 } else if (uri.authority.equals("x-taler-bank", true)) { 308 PaytoUriTalerBank.fromString(uri)?.let { data -> 309 TransferData.Taler( 310 account = data.account, 311 receiverName = data.receiverName, 312 subject = uri.getQueryParameter("message") ?: return@let null, 313 amountRaw = amountRaw, 314 amountEffective = amountEffective, 315 exchangeBaseUrl = data.host, 316 transferAmount = transferAmount, 317 withdrawalAccount = copy(paytoUri = uri.toString()) 318 ) 319 } 320 } else if (uri.authority.equals("iban", true)) { 321 PaytoUriIban.fromString(uri)?.let { data -> 322 TransferData.IBAN( 323 iban = data.iban, 324 receiverName = data.receiverName, 325 receiverTown = data.receiverTown, 326 receiverPostalCode = data.receiverPostalCode, 327 subject = uri.getQueryParameter("message") ?: return@let null, 328 amountRaw = amountRaw, 329 amountEffective = amountEffective, 330 transferAmount = transferAmount, 331 withdrawalAccount = copy(paytoUri = uri.toString()), 332 ) 333 } 334 } else if (uri.authority.equals("cyclos", true)) { 335 PaytoUriCyclos.fromString(uri)?.let { data -> 336 TransferData.Cyclos( 337 account = data.account, 338 receiverName = data.receiverName, 339 host = data.host, 340 subject = uri.getQueryParameter("message") ?: return@let null, 341 amountRaw = amountRaw, 342 amountEffective = amountEffective, 343 transferAmount = transferAmount 344 .withSpec(currencySpecification), 345 withdrawalAccount = copy(paytoUri = uri.toString()), 346 ) 347 } 348 } else null 349 } 350 } 351 352 @Serializable 353 sealed class AccountRestriction { 354 @Serializable 355 @SerialName("deny") 356 data object DenyAllAccount: AccountRestriction() 357 358 @Serializable 359 @SerialName("regex") 360 data class RegexAccount( 361 // Regular expression that the payto://-URI of the 362 // partner account must follow. The regular expression 363 // should follow posix-egrep, but without support for character 364 // classes, GNU extensions, back-references or intervals. See 365 // https://www.gnu.org/software/findutils/manual/html_node/find_html/posix_002degrep-regular-expression-syntax.html 366 // for a description of the posix-egrep syntax. Applications 367 // may support regexes with additional features, but exchanges 368 // must not use such regexes. 369 @SerialName("payto_regex") 370 val paytoRegex: String, 371 372 // Hint for a human to understand the restriction 373 // (that is hopefully easier to comprehend than the regex itself). 374 @SerialName("human_hint") 375 val humanHint: String, 376 377 // Map from IETF BCP 47 language tags to localized 378 // human hints. 379 @SerialName("human_hint_i18n") 380 val humanHintI18n: Map<String, String>? = null, 381 ): AccountRestriction() 382 } 383 384 @Serializable 385 @SerialName("payment") 386 class TransactionPayment( 387 override val transactionId: String, 388 override val timestamp: Timestamp, 389 override val txState: TransactionState, 390 override val txActions: List<TransactionAction>, 391 val info: TransactionInfo? = null, 392 override val error: TalerErrorInfo? = null, 393 override val amountRaw: Amount, 394 override val amountEffective: Amount, 395 override val scopes: List<ScopeInfo>, 396 val posConfirmation: String? = null, 397 ) : Transaction() { 398 override val icon = R.drawable.transaction_payment 399 override val detailPageNav = WalletDestination.TransactionPayment 400 401 @Transient 402 override val amountType = AmountType.Negative 403 @Composable 404 override fun getTitle() = info?.merchant?.name 405 ?: stringResource(R.string.payment_title) 406 override val generalTitleRes = R.string.payment_title 407 } 408 409 @Serializable 410 class TransactionInfo( 411 val orderId: String, 412 val merchant: Merchant, 413 val summary: String, 414 @SerialName("summary_i18n") 415 val summaryI18n: Map<String, String>? = null, 416 val products: List<ContractProduct> = emptyList(), 417 val fulfillmentUrl: String? = null, 418 /** 419 * Message shown to the user after the payment is complete. 420 */ 421 val fulfillmentMessage: String? = null, 422 /** 423 * Map from IETF BCP 47 language tags to localized fulfillment messages 424 */ 425 val fulfillmentMessage_i18n: Map<String, String>? = null, 426 ) 427 428 @Serializable 429 @SerialName("refund") 430 class TransactionRefund( 431 override val transactionId: String, 432 override val timestamp: Timestamp, 433 override val txState: TransactionState, 434 override val txActions: List<TransactionAction>, 435 val refundedTransactionId: String, 436 val paymentInfo: RefundPaymentInfo? = null, 437 override val error: TalerErrorInfo? = null, 438 override val amountRaw: Amount, 439 override val amountEffective: Amount, 440 override val scopes: List<ScopeInfo>, 441 ) : Transaction() { 442 override val icon = R.drawable.transaction_refund 443 override val detailPageNav = WalletDestination.TransactionRefund 444 445 @Transient 446 override val amountType = AmountType.Positive 447 @Composable 448 override fun getTitle() = paymentInfo?.merchant?.name ?: stringResource(R.string.transaction_refund) 449 450 override val generalTitleRes = R.string.refund_title 451 } 452 453 @Serializable 454 @SerialName("refresh") 455 class TransactionRefresh( 456 override val transactionId: String, 457 override val timestamp: Timestamp, 458 override val txState: TransactionState, 459 override val txActions: List<TransactionAction>, 460 override val error: TalerErrorInfo? = null, 461 override val amountRaw: Amount, 462 override val amountEffective: Amount, 463 override val scopes: List<ScopeInfo>, 464 ) : Transaction() { 465 override val icon = R.drawable.transaction_refresh 466 override val detailPageNav = WalletDestination.TransactionRefresh 467 468 @Transient 469 override val amountType = AmountType.Negative 470 @Composable 471 override fun getTitle(): String { 472 return stringResource(R.string.transaction_refresh) 473 } 474 475 override val generalTitleRes = R.string.transaction_refresh 476 } 477 478 @Serializable 479 @SerialName("deposit") 480 class TransactionDeposit( 481 override val transactionId: String, 482 override val timestamp: Timestamp, 483 override val txState: TransactionState, 484 override val txActions: List<TransactionAction>, 485 val kycUrl: String? = null, 486 val kycAuthTransferInfo: KycAuthTransferInfo? = null, 487 override val error: TalerErrorInfo? = null, 488 override val amountRaw: Amount, 489 override val amountEffective: Amount, 490 override val scopes: List<ScopeInfo>, 491 val targetPaytoUri: String, 492 val depositGroupId: String, 493 ) : Transaction() { 494 override val icon = R.drawable.transaction_deposit 495 override val detailPageNav = WalletDestination.TransactionDeposit 496 497 @Transient 498 override val amountType = AmountType.Negative 499 @Composable 500 override fun getTitle(): String { 501 val uri = Uri.parse(targetPaytoUri) 502 return uri.getQueryParameter("receiver-name")?.let { receiverName -> 503 stringResource(R.string.transaction_deposit_to, receiverName) 504 } ?: stringResource(R.string.transaction_deposit) 505 } 506 507 override val generalTitleRes = R.string.transaction_deposit 508 } 509 510 @Serializable 511 data class KycAuthTransferInfo( 512 val debitPaytoUri: String, 513 val accountPub: String, 514 val creditPaytoUris: List<String>, 515 ) 516 517 @Serializable 518 data class PeerInfoShort( 519 val expiration: Timestamp? = null, 520 val summary: String? = null, 521 ) 522 523 /** 524 * Debit because we paid someone's invoice. 525 */ 526 @Serializable 527 @SerialName("peer-pull-debit") 528 class TransactionPeerPullDebit( 529 override val transactionId: String, 530 override val timestamp: Timestamp, 531 override val txState: TransactionState, 532 override val txActions: List<TransactionAction>, 533 val exchangeBaseUrl: String, 534 override val error: TalerErrorInfo? = null, 535 override val amountRaw: Amount, 536 override val amountEffective: Amount, 537 override val scopes: List<ScopeInfo>, 538 val info: PeerInfoShort, 539 ) : Transaction() { 540 override val icon = R.drawable.transaction_p2p_outgoing 541 override val detailPageNav = WalletDestination.TransactionPeer 542 543 @Transient 544 override val amountType = AmountType.Negative 545 @Composable 546 override fun getTitle(): String { 547 return if (txState.major == Done) { 548 stringResource(R.string.transaction_peer_pull_debit) 549 } else { 550 stringResource(R.string.transaction_peer_pull_debit_pending) 551 } 552 } 553 554 override val generalTitleRes = R.string.transaction_peer_pull_debit 555 } 556 557 /** 558 * Credit because someone paid for an invoice we created. 559 */ 560 @Serializable 561 @SerialName("peer-pull-credit") 562 class TransactionPeerPullCredit( 563 override val transactionId: String, 564 override val timestamp: Timestamp, 565 override val txState: TransactionState, 566 override val txActions: List<TransactionAction>, 567 val kycUrl: String? = null, 568 val exchangeBaseUrl: String, 569 override val error: TalerErrorInfo? = null, 570 override val amountRaw: Amount, 571 override val amountEffective: Amount, 572 override val scopes: List<ScopeInfo>, 573 val info: PeerInfoShort, 574 val talerUri: String, 575 // val completed: Boolean, maybe 576 ) : Transaction() { 577 override val icon = R.drawable.transaction_p2p_incoming 578 override val detailPageNav = WalletDestination.TransactionPeer 579 580 override val amountType get() = AmountType.Positive 581 @Composable 582 override fun getTitle(): String { 583 return stringResource(R.string.transaction_peer_pull_credit) 584 } 585 586 override val generalTitleRes = R.string.transaction_peer_pull_credit 587 } 588 589 /** 590 * Debit because we sent money to someone. 591 */ 592 @Serializable 593 @SerialName("peer-push-debit") 594 class TransactionPeerPushDebit( 595 override val transactionId: String, 596 override val timestamp: Timestamp, 597 override val txState: TransactionState, 598 override val txActions: List<TransactionAction>, 599 val exchangeBaseUrl: String, 600 override val error: TalerErrorInfo? = null, 601 override val amountRaw: Amount, 602 override val amountEffective: Amount, 603 override val scopes: List<ScopeInfo>, 604 val info: PeerInfoShort, 605 val talerUri: String? = null, 606 // val completed: Boolean, definitely 607 ) : Transaction() { 608 override val icon = R.drawable.transaction_p2p_outgoing 609 override val detailPageNav = WalletDestination.TransactionPeer 610 611 @Transient 612 override val amountType = AmountType.Negative 613 @Composable 614 override fun getTitle(): String { 615 return if (txState.major == Done) { 616 stringResource(R.string.transaction_peer_push_debit) 617 } else { 618 stringResource(R.string.transaction_peer_push_debit_pending) 619 } 620 } 621 622 override val generalTitleRes = R.string.payment_title 623 } 624 625 /** 626 * We received money via a peer payment. 627 */ 628 @Serializable 629 @SerialName("peer-push-credit") 630 class TransactionPeerPushCredit( 631 override val transactionId: String, 632 override val timestamp: Timestamp, 633 override val txState: TransactionState, 634 override val txActions: List<TransactionAction>, 635 val kycUrl: String? = null, 636 val exchangeBaseUrl: String, 637 override val error: TalerErrorInfo? = null, 638 override val amountRaw: Amount, 639 override val amountEffective: Amount, 640 override val scopes: List<ScopeInfo>, 641 val info: PeerInfoShort, 642 ) : Transaction() { 643 override val icon = R.drawable.transaction_p2p_incoming 644 override val detailPageNav = WalletDestination.TransactionPeer 645 646 @Transient 647 override val amountType = AmountType.Positive 648 @Composable 649 override fun getTitle(): String { 650 return if (txState.major == Done) { 651 stringResource(R.string.transaction_peer_push_credit) 652 } else { 653 stringResource(R.string.transaction_peer_push_credit_pending) 654 } 655 } 656 657 override val generalTitleRes = R.string.transaction_peer_push_credit 658 } 659 660 /** 661 * A transaction to indicate financial loss due to denominations 662 * that became unusable for deposits. 663 */ 664 @Serializable 665 @SerialName("denom-loss") 666 class TransactionDenomLoss( 667 override val transactionId: String, 668 override val timestamp: Timestamp, 669 override val txState: TransactionState, 670 override val txActions: List<TransactionAction>, 671 override val error: TalerErrorInfo? = null, 672 override val amountRaw: Amount, 673 override val amountEffective: Amount, 674 override val scopes: List<ScopeInfo>, 675 val lossEventType: LossEventType, 676 ): Transaction() { 677 override val icon: Int = R.drawable.transaction_loss 678 override val detailPageNav = WalletDestination.TransactionLoss 679 680 @Transient 681 override val amountType: AmountType = AmountType.Negative 682 683 @Composable 684 override fun getTitle(): String { 685 return stringResource(R.string.transaction_denom_loss) 686 } 687 688 override val generalTitleRes: Int = R.string.transaction_denom_loss 689 } 690 691 @Serializable 692 enum class LossEventType { 693 @SerialName("denom-expired") 694 DenomExpired, 695 696 @SerialName("denom-vanished") 697 DenomVanished, 698 699 @SerialName("denom-unoffered") 700 DenomUnoffered 701 } 702 703 /** 704 * This represents a transaction that we can not parse for some reason. 705 */ 706 class DummyTransaction( 707 override val transactionId: String, 708 override val timestamp: Timestamp, 709 override val error: TalerErrorInfo, 710 ) : Transaction() { 711 override val txState: TransactionState = TransactionState(None) 712 override val txActions: List<TransactionAction> = emptyList() 713 override val amountRaw: Amount = Amount.zero("TESTKUDOS") 714 override val amountEffective: Amount = Amount.zero("TESTKUDOS") 715 override val icon: Int = R.drawable.transaction_dummy 716 override val detailPageNav: WalletDestination = WalletDestination.TransactionDummy 717 override val amountType: AmountType = AmountType.Neutral 718 override val generalTitleRes: Int = R.string.transaction_dummy_title 719 override val scopes: List<ScopeInfo> = listOf(ScopeInfo.Exchange( 720 currency = "TESTKUDOS", 721 url = "exchange.test.taler.net", 722 )) 723 @Composable 724 override fun getTitle(): String { 725 return stringResource(R.string.transaction_dummy_title) 726 } 727 }