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:
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