taler-android

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

commit 53debf513316a6e122e1a0d64c9c432fcfe0e132
parent 61bf9637a6d49001cdbd7e91817af207f01ef050
Author: Iván Ávalos <avalos@disroot.org>
Date:   Thu, 20 Aug 2026 00:31:44 +0200

[wallet] harden notification decoding and preserve unknown transaction states

Diffstat:
Mwallet/src/main/java/net/taler/wallet/backend/BackendManager.kt | 29++++++++++++++++++-----------
Mwallet/src/main/java/net/taler/wallet/backend/Notifications.kt | 16++++++++--------
Mwallet/src/main/java/net/taler/wallet/main/MainViewModel.kt | 28++++++++++++++++------------
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionState.kt | 261+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Awallet/src/test/java/net/taler/wallet/transactions/TransactionStateTest.kt | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 250 insertions(+), 157 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/backend/BackendManager.kt b/wallet/src/main/java/net/taler/wallet/backend/BackendManager.kt @@ -32,6 +32,7 @@ import net.taler.qtart.TalerWalletCore import net.taler.wallet.BuildConfig import org.json.JSONObject import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.resume @@ -129,19 +130,25 @@ class BackendManager( private fun onMessageReceived(msg: String) = scope.launch { Log.d(TAG, "message received: $msg") - when (val message = json.decodeFromString<ApiMessage>(msg)) { - is ApiMessage.Notification -> { - notificationReceiver.onNotificationReceived(message.payload) - } - is ApiResponse -> { - val id = message.id - val cont = requestManager.getAndRemoveContinuation(id) - if (cont == null) { - Log.e(TAG, "wallet returned unknown request ID ($id)") - } else { - cont.resume(message) + try { + when (val message = json.decodeFromString<ApiMessage>(msg)) { + is ApiMessage.Notification -> { + notificationReceiver.onNotificationReceived(message.payload) + } + is ApiResponse -> { + val id = message.id + val cont = requestManager.getAndRemoveContinuation(id) + if (cont == null) { + Log.e(TAG, "wallet returned unknown request ID ($id)") + } else { + cont.resume(message) + } } } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "failed to decode message from wallet-core", e) } } } diff --git a/wallet/src/main/java/net/taler/wallet/backend/Notifications.kt b/wallet/src/main/java/net/taler/wallet/backend/Notifications.kt @@ -44,32 +44,32 @@ sealed class NotificationPayload { * to the wallet and not visible to the user. */ val isInternal: Boolean? = null, - val hintTransactionId: String, + val hintTransactionId: String? = null, ) : NotificationPayload() @Serializable @SerialName("transaction-state-transition") data class TransactionStateTransition( - val transactionId: String, + val transactionId: String? = null, val causeHint: String? = null, - val oldTxState: TransactionState, - val newTxState: TransactionState, + val oldTxState: TransactionState? = null, + val newTxState: TransactionState? = null, val errorInfo: TalerErrorInfo? = null, ) : NotificationPayload() @Serializable @SerialName("task-observability-event") data class TaskObservabilityEvent( - val taskId: String, - val event: ObservabilityEvent, + val taskId: String? = null, + val event: ObservabilityEvent? = null, ) : NotificationPayload() @Serializable @SerialName("request-observability-event") data class RequestObservabilityEvent( val requestId: Int, - val operation: String, - val event: ObservabilityEvent, + val operation: String? = null, + val event: ObservabilityEvent? = null, ) : NotificationPayload() @Serializable diff --git a/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt @@ -166,13 +166,15 @@ class MainViewModel( is NotificationPayload.TransactionStateTransition -> { viewModelScope.launch(Dispatchers.Main) { - // update currently selected transaction - transactionManager.updateTransactionIfSelected(payload.transactionId) - // update currently selected transaction list - transactionManager.getTransactionById(payload.transactionId)?.let { tx -> - val v = viewMode.value - if (v is ViewMode.Transactions && v.selectedScope in tx.scopes) { - transactionManager.loadTransactions(v.selectedScope) + payload.transactionId?.let { id -> + // update currently selected transaction + transactionManager.updateTransactionIfSelected(id) + // update currently selected transaction list + transactionManager.getTransactionById(id)?.let { tx -> + val v = viewMode.value + if (v is ViewMode.Transactions && v.selectedScope in tx.scopes) { + transactionManager.loadTransactions(v.selectedScope) + } } } } @@ -184,11 +186,13 @@ class MainViewModel( is NotificationPayload.TaskObservabilityEvent -> payload.event is NotificationPayload.RequestObservabilityEvent -> payload.event } - mObservabilityLog.getAndUpdate { logs -> - logs.takeLast(OBSERVABILITY_LIMIT) - .toMutableList().apply { - add(event) - } + if (event != null) { + mObservabilityLog.getAndUpdate { logs -> + logs.takeLast(OBSERVABILITY_LIMIT) + .toMutableList().apply { + add(event) + } + } } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionState.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionState.kt @@ -16,13 +16,37 @@ package net.taler.wallet.transactions -import kotlinx.serialization.SerialName +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable - -@Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +@Serializable(with = TransactionStateSerializer::class) data class TransactionState( val major: TransactionMajorState, val minor: TransactionMinorState? = null, + /** + * Exact serialized name of [major] as received from wallet-core. + * Only set when [major] is [TransactionMajorState.Unknown], so that + * unrecognized/future states survive a serialize round trip. + */ + val majorName: String? = null, + /** + * Exact serialized name of [minor] as received from wallet-core. + * Only set when [minor] is [TransactionMinorState.Unknown]. + */ + val minorName: String? = null, ) { override fun equals(other: Any?): Boolean { return if (other is TransactionState) @@ -38,141 +62,126 @@ data class TransactionState( } } -@Serializable -enum class TransactionMajorState { - @SerialName("unknown") - Unknown, - - @SerialName("none") - None, - - @SerialName("pending") - Pending, - - @SerialName("done") - Done, - - @SerialName("aborting") - Aborting, - - @SerialName("aborted") - Aborted, - - @SerialName("dialog") - Dialog, - - @SerialName("finalizing") - Finalizing, - - @SerialName("suspended") - Suspended, - - @SerialName("suspended-finalizing") - SuspendedFinalizing, - - @SerialName("suspended-aborting") - SuspendedAborting, - - @SerialName("failed") - Failed, - - @SerialName("expired") - Expired, - - @SerialName("deleted") - Deleted, +@Serializable(with = TransactionMajorStateSerializer::class) +enum class TransactionMajorState(val serialName: String) { + Unknown("unknown"), + None("none"), + Pending("pending"), + Done("done"), + Aborting("aborting"), + Aborted("aborted"), + Dialog("dialog"), + Finalizing("finalizing"), + Suspended("suspended"), + SuspendedFinalizing("suspended-finalizing"), + SuspendedAborting("suspended-aborting"), + Failed("failed"), + Expired("expired"), + Deleted("deleted"), } -@Serializable -enum class TransactionMinorState { - @SerialName("aborting-bank") - AbortingBank, - - @SerialName("accept-refund") - AcceptRefund, - - @SerialName("auto-refund") - AutoRefund, - - @SerialName("balance-kyc") - BalanceKycRequired, - - @SerialName("bank") - Bank, - - @SerialName("bank-confirm-transfer") - BankConfirmTransfer, - - @SerialName("bank-register-reserve") - BankRegisterReserve, +object TransactionMajorStateSerializer : KSerializer<TransactionMajorState> { + private val serialNames = TransactionMajorState.entries.associateBy { it.serialName } - @SerialName("check-refund") - CheckRefund, + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("TransactionMajorState", PrimitiveKind.STRING) - @SerialName("claim-proposal") - ClaimProposal, + override fun deserialize(decoder: Decoder): TransactionMajorState = + fromName(decoder.decodeString()) - @SerialName("completed-by-another-wallet") - CompletedByAnotherWallet, - - @SerialName("create-purse") - CreatePurse, - - @SerialName("delete-purse") - DeletePurse, - - @SerialName("deposit") - Deposit, - - @SerialName("exchange") - Exchange, - - @SerialName("exchange-wait-reserve") - ExchangeWaitReserve, - - @SerialName("kyc-auth") - KycAuthRequired, - - @SerialName("kyc-init") - KycInit, - - @SerialName("kyc") - KycRequired, - - @SerialName("merge") - Merge, - - @SerialName("paid-by-other") - PaidByOther, - - @SerialName("proposed") - Proposed, - - @SerialName("ready") - Ready, + override fun serialize(encoder: Encoder, value: TransactionMajorState) { + encoder.encodeString(value.serialName) + } - @SerialName("rebind-session") - RebindSession, + fun fromName(serialName: String): TransactionMajorState = + serialNames[serialName] ?: TransactionMajorState.Unknown +} - @SerialName("refresh") - Refresh, +@Serializable(with = TransactionMinorStateSerializer::class) +enum class TransactionMinorState(val serialName: String) { + AbortingBank("aborting-bank"), + AcceptRefund("accept-refund"), + AutoRefund("auto-refund"), + BalanceKycRequired("balance-kyc"), + Bank("bank"), + BankConfirmTransfer("bank-confirm-transfer"), + BankRegisterReserve("bank-register-reserve"), + CheckRefund("check-refund"), + ClaimProposal("claim-proposal"), + CompletedByOtherWallet("completed-by-other-wallet"), + CreatePurse("create-purse"), + DeletePurse("delete-purse"), + Deposit("deposit"), + Exchange("exchange"), + ExchangeWaitReserve("exchange-wait-reserve"), + KycAuthRequired("kyc-auth"), + KycInit("kyc-init"), + KycRequired("kyc"), + Merge("merge"), + PaidByOther("paid-by-other"), + Proposed("proposed"), + Ready("ready"), + RebindSession("rebind-session"), + Refresh("refresh"), + Refused("refused"), + Repurchase("repurchase"), + SubmitPayment("submit-payment"), + Track("track"), + Unknown("unknown"), + Withdraw("withdraw"), + Abort("abort"), +} - @SerialName("refused") - Refused, +object TransactionMinorStateSerializer : KSerializer<TransactionMinorState> { + private val serialNames = TransactionMinorState.entries.associateBy { it.serialName } - @SerialName("repurchase") - Repurchase, + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("TransactionMinorState", PrimitiveKind.STRING) - @SerialName("submit-payment") - SubmitPayment, + override fun deserialize(decoder: Decoder): TransactionMinorState = + fromName(decoder.decodeString()) - @SerialName("track") - Track, + override fun serialize(encoder: Encoder, value: TransactionMinorState) { + encoder.encodeString(value.serialName) + } - @SerialName("unknown") - Unknown, + fun fromName(serialName: String): TransactionMinorState = + serialNames[serialName] ?: TransactionMinorState.Unknown +} - @SerialName("withdraw") - Withdraw, +object TransactionStateSerializer : KSerializer<TransactionState> { + override val descriptor: SerialDescriptor = JsonObject.serializer().descriptor + + override fun deserialize(decoder: Decoder): TransactionState { + val jsonDecoder = decoder as? JsonDecoder + ?: error("TransactionStateSerializer can only be used with Json") + val obj = jsonDecoder.decodeJsonElement().jsonObject + val majorName = obj["major"]?.jsonPrimitive?.contentOrNull ?: "unknown" + val minorName = obj["minor"]?.jsonPrimitive?.contentOrNull + return TransactionState( + major = TransactionMajorStateSerializer.fromName(majorName), + minor = minorName?.let(TransactionMinorStateSerializer::fromName), + majorName = majorName, + minorName = minorName, + ) + } + override fun serialize(encoder: Encoder, value: TransactionState) { + val jsonEncoder = encoder as? JsonEncoder + ?: error("TransactionStateSerializer can only be used with Json") + jsonEncoder.encodeJsonElement(buildJsonObject { + put( + "major", + JsonPrimitive( + if (value.major == TransactionMajorState.Unknown && value.majorName != null) value.majorName + else value.major.serialName, + ), + ) + val minorName = value.minor?.let { minor -> + if (minor == TransactionMinorState.Unknown && value.minorName != null) value.minorName + else minor.serialName + } ?: value.minorName + if (minorName != null) put("minor", JsonPrimitive(minorName)) + }) + } } diff --git a/wallet/src/test/java/net/taler/wallet/transactions/TransactionStateTest.kt b/wallet/src/test/java/net/taler/wallet/transactions/TransactionStateTest.kt @@ -0,0 +1,72 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.transactions + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Test + +class TransactionStateTest { + + private fun decode(s: String): TransactionState = + Json.decodeFromString(TransactionState.serializer(), s) + + private fun encode(state: TransactionState): String = + Json.encodeToString(TransactionState.serializer(), state) + + @Test + fun testUnknownStatesRoundTrip() { + val state = decode("""{"major":"future-major","minor":"future-minor"}""") + assertEquals(TransactionMajorState.Unknown, state.major) + assertEquals(TransactionMinorState.Unknown, state.minor) + assertEquals("future-major", state.majorName) + assertEquals("future-minor", state.minorName) + assertEquals("""{"major":"future-major","minor":"future-minor"}""", encode(state)) + } + + @Test + fun testUnknownMinorRoundTrip() { + val state = decode("""{"major":"pending","minor":"future-minor"}""") + assertEquals(TransactionMajorState.Pending, state.major) + assertEquals(TransactionMinorState.Unknown, state.minor) + assertEquals("future-minor", state.minorName) + assertEquals("""{"major":"pending","minor":"future-minor"}""", encode(state)) + } + + @Test + fun testKnownStatesRoundTrip() { + val state = decode("""{"major":"pending","minor":"bank"}""") + assertEquals(TransactionMajorState.Pending, state.major) + assertEquals(TransactionMinorState.Bank, state.minor) + assertEquals("""{"major":"pending","minor":"bank"}""", encode(state)) + } + + @Test + fun testCodeConstructedState() { + assertEquals("""{"major":"pending"}""", encode(TransactionState(TransactionMajorState.Pending))) + assertEquals( + """{"major":"pending","minor":"bank"}""", + encode(TransactionState(TransactionMajorState.Pending, TransactionMinorState.Bank)), + ) + } + + @Test + fun testCopyDoesNotStaleName() { + val state = decode("""{"major":"future-major"}""").copy(major = TransactionMajorState.Done) + assertEquals("""{"major":"done"}""", encode(state)) + } +} +\ No newline at end of file