commit 61bf9637a6d49001cdbd7e91817af207f01ef050
parent c556e23ab0c907252c3df2ae1564ffd16fdf532f
Author: Iván Ávalos <avalos@disroot.org>
Date: Thu, 20 Aug 2026 00:01:39 +0200
[wallet] cleanup notification handling + listen to database-maintenance-progress
Diffstat:
7 files changed, 207 insertions(+), 42 deletions(-)
diff --git a/wallet/src/main/java/net/taler/wallet/backend/ApiResponse.kt b/wallet/src/main/java/net/taler/wallet/backend/ApiResponse.kt
@@ -19,30 +19,18 @@ package net.taler.wallet.backend
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
-import net.taler.wallet.events.ObservabilityEvent
@Serializable
sealed class ApiMessage {
-
@Serializable
@SerialName("notification")
data class Notification(
val payload: NotificationPayload,
) : ApiMessage()
-
}
@Serializable
-data class NotificationPayload(
- val type: String,
- val id: String? = null,
- val event: ObservabilityEvent? = null,
- val transactionId: String? = null,
-)
-
-@Serializable
sealed class ApiResponse : ApiMessage() {
-
abstract val id: Int
abstract val operation: String
diff --git a/wallet/src/main/java/net/taler/wallet/backend/Notifications.kt b/wallet/src/main/java/net/taler/wallet/backend/Notifications.kt
@@ -0,0 +1,132 @@
+/*
+ * 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.backend
+
+import kotlinx.serialization.DeserializationStrategy
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.JsonContentPolymorphicSerializer
+import kotlinx.serialization.json.JsonDecoder
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonEncoder
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import net.taler.wallet.events.ObservabilityEvent
+import net.taler.wallet.transactions.TransactionState
+
+@Serializable(with = NotificationPayloadSerializer::class)
+sealed class NotificationPayload {
+ @Serializable
+ @SerialName("balance-change")
+ data class BalanceChange(
+ /**
+ * If set to true, the balance change is internal
+ * to the wallet and not visible to the user.
+ */
+ val isInternal: Boolean? = null,
+ val hintTransactionId: String,
+ ) : NotificationPayload()
+
+ @Serializable
+ @SerialName("transaction-state-transition")
+ data class TransactionStateTransition(
+ val transactionId: String,
+ val causeHint: String? = null,
+ val oldTxState: TransactionState,
+ val newTxState: TransactionState,
+ val errorInfo: TalerErrorInfo? = null,
+ ) : NotificationPayload()
+
+ @Serializable
+ @SerialName("task-observability-event")
+ data class TaskObservabilityEvent(
+ val taskId: String,
+ val event: ObservabilityEvent,
+ ) : NotificationPayload()
+
+ @Serializable
+ @SerialName("request-observability-event")
+ data class RequestObservabilityEvent(
+ val requestId: Int,
+ val operation: String,
+ val event: ObservabilityEvent,
+ ) : NotificationPayload()
+
+ @Serializable
+ @SerialName("database-maintenance-progress")
+ data class DatabaseMaintenanceProgress(
+ val operation: String,
+ val phase: String,
+ ) : NotificationPayload()
+
+ @Serializable(with = UnknownPayloadSerializer::class)
+ data class Unknown(
+ val type: String? = null,
+ val raw: JsonObject,
+ ) : NotificationPayload()
+}
+
+object NotificationPayloadSerializer :
+ JsonContentPolymorphicSerializer<NotificationPayload>(NotificationPayload::class) {
+
+ override fun selectDeserializer(element: JsonElement): DeserializationStrategy<NotificationPayload> {
+ val type = element.jsonObject["type"]?.jsonPrimitive?.contentOrNull
+
+ val serializer: DeserializationStrategy<NotificationPayload> = when (type) {
+ "balance-change" -> NotificationPayload.BalanceChange.serializer()
+ "transaction-state-transition" -> NotificationPayload.TransactionStateTransition.serializer()
+ "task-observability-event" -> NotificationPayload.TaskObservabilityEvent.serializer()
+ "request-observability-event" -> NotificationPayload.RequestObservabilityEvent.serializer()
+ "database-maintenance-progress" -> NotificationPayload.DatabaseMaintenanceProgress.serializer()
+ else -> NotificationPayload.Unknown.serializer()
+ }
+
+ return serializer
+ }
+}
+
+object UnknownPayloadSerializer : KSerializer<NotificationPayload.Unknown> {
+ // Delegate descriptor to JsonObject.serializer().descriptor so kotlinx.serialization
+ // knows this serializer operates directly on a raw JSON object structure.
+ override val descriptor: SerialDescriptor = JsonObject.serializer().descriptor
+
+ override fun deserialize(decoder: Decoder): NotificationPayload.Unknown {
+ val jsonDecoder = decoder as? JsonDecoder
+ ?: error("UnknownPayloadSerializer can only be used with Json")
+
+ val jsonObject = jsonDecoder.decodeJsonElement().jsonObject
+ val type = jsonObject["type"]?.jsonPrimitive?.contentOrNull
+
+ return NotificationPayload.Unknown(
+ type = type,
+ raw = jsonObject,
+ )
+ }
+
+ override fun serialize(encoder: Encoder, value: NotificationPayload.Unknown) {
+ val jsonEncoder = encoder as? JsonEncoder
+ ?: error("UnknownPayloadSerializer can only be used with Json")
+
+ jsonEncoder.encodeJsonElement(value.raw)
+ }
+}
+\ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt b/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt
@@ -96,6 +96,7 @@ fun MainScreen(
val networkStatus by model.networkManager.networkStatus.observeAsState(false)
val balanceState by model.balanceManager.state.observeAsState(BalanceState.None)
val viewMode by model.viewMode.collectAsStateLifecycleAware()
+ val dbMigrationStatus by model.dbMigrationStatus.collectAsStateLifecycleAware()
val devMode by model.devMode.observeAsState(false)
val txResult by remember(viewMode) {
val v = viewMode as? ViewMode.Transactions
@@ -205,6 +206,21 @@ fun MainScreen(
model.settingsManager.saveViewMode(context, viewMode)
}
+ val migrateCompleteMsg = stringResource(R.string.settings_db_migrate_complete)
+ val migrateFailedMsg = stringResource(R.string.settings_db_migrate_failed)
+ LaunchedEffect(dbMigrationStatus) {
+ when (dbMigrationStatus) {
+ DbMigrationStatus.Complete ->
+ snackbarHostState.showSnackbar(migrateCompleteMsg)
+
+ DbMigrationStatus.Failed ->
+ snackbarHostState.showSnackbar(migrateFailedMsg)
+
+ else -> {}
+ }
+ model.resetDbMigrationStatus()
+ }
+
BackHandler(selectionMode || (tab == MainTab.ASSETS && viewMode !is ViewMode.Assets)) {
if (selectionMode) {
selectionMode = false
diff --git a/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt
@@ -63,15 +63,6 @@ import net.taler.wallet.tokens.TokenManager
const val TAG = "taler-wallet"
const val OBSERVABILITY_LIMIT = 100
-private val transactionNotifications = listOf(
- "transaction-state-transition",
-)
-
-private val observabilityNotifications = listOf(
- "task-observability-event",
- "request-observability-event",
-)
-
class MainViewModel(
app: Application,
) : AndroidViewModel(app), InitReceiver, NotificationReceiver {
@@ -137,6 +128,9 @@ class MainViewModel(
private val mInitError = MutableStateFlow<TalerErrorInfo?>(null)
val initError: StateFlow<TalerErrorInfo?> = mInitError
+ private val mDbMigrationStatus = MutableStateFlow(DbMigrationStatus.None)
+ val dbMigrationStatus: StateFlow<DbMigrationStatus> = mDbMigrationStatus
+
fun startWallet() {
api.startWallet()
}
@@ -159,32 +153,23 @@ class MainViewModel(
}
override fun onNotificationReceived(payload: NotificationPayload) {
- if (payload.type == "waiting-for-retry") return // ignore ping)
-
val str = BackendManager.json.encodeToString(payload)
Log.i(TAG, "Received notification from wallet-core: $str")
- // Only update balances when we're told they changed
- if (payload.type == "balance-change") viewModelScope.launch(Dispatchers.Main) {
- balanceManager.loadAssets()
- }
-
- if (payload.type in observabilityNotifications && payload.event != null) {
- mObservabilityLog.getAndUpdate { logs ->
- logs.takeLast(OBSERVABILITY_LIMIT)
- .toMutableList().apply {
- add(payload.event)
- }
+ when (payload) {
+ // Only update balances when we're told they changed
+ is NotificationPayload.BalanceChange -> {
+ viewModelScope.launch(Dispatchers.Main) {
+ balanceManager.loadAssets()
+ }
}
- }
- if (payload.type in transactionNotifications) viewModelScope.launch(Dispatchers.Main) {
- payload.transactionId?.let { id ->
- // update currently selected transaction
- transactionManager.updateTransactionIfSelected(id)
- // update currently selected transaction list
- if (payload.type == "transaction-state-transition") {
- transactionManager.getTransactionById(id)?.let { tx ->
+ 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)
@@ -192,6 +177,32 @@ class MainViewModel(
}
}
}
+
+ is NotificationPayload.TaskObservabilityEvent,
+ is NotificationPayload.RequestObservabilityEvent -> {
+ val event = when(payload) {
+ is NotificationPayload.TaskObservabilityEvent -> payload.event
+ is NotificationPayload.RequestObservabilityEvent -> payload.event
+ }
+ mObservabilityLog.getAndUpdate { logs ->
+ logs.takeLast(OBSERVABILITY_LIMIT)
+ .toMutableList().apply {
+ add(event)
+ }
+ }
+ }
+
+ is NotificationPayload.DatabaseMaintenanceProgress -> {
+ if (payload.operation == "indexeddb-to-native-migration") {
+ if (payload.phase == "complete") {
+ mDbMigrationStatus.value = DbMigrationStatus.Complete
+ } else if (payload.phase == "failed") {
+ mDbMigrationStatus.value = DbMigrationStatus.Failed
+ }
+ }
+ }
+
+ else -> {}
}
}
@@ -296,6 +307,10 @@ class MainViewModel(
}
}
+ fun resetDbMigrationStatus() {
+ mDbMigrationStatus.value = DbMigrationStatus.None
+ }
+
fun showObservabilityLog() {
mShowObservabilityLog.value = true
}
@@ -326,3 +341,9 @@ sealed class AmountResult {
data class InsufficientBalance(val amount: Amount) : AmountResult()
data object InvalidAmount : AmountResult()
}
+
+enum class DbMigrationStatus {
+ None,
+ Complete,
+ Failed,
+}
+\ No newline at end of file
diff --git a/wallet/src/main/res/values-de/strings.xml b/wallet/src/main/res/values-de/strings.xml
@@ -181,6 +181,8 @@
<string name="settings_db_import_success">Datenbank aus Datei importiert</string>
<string name="settings_db_migrate_canceled">Migration abgebrochen</string>
<string name="settings_db_migrate_done">Datenbankmigration gestartet</string>
+ <string name="settings_db_migrate_complete">SQLite-Datenbankmigration abgeschlossen</string>
+ <string name="settings_db_migrate_failed">SQLite-Datenbankmigration fehlgeschlagen</string>
<string name="settings_migrate_db">Datenbank migrieren (experimentell!)</string>
<string name="settings_migrate_db_summary">Auf die neue SQLite-Datenbank umstellen</string>
<string name="settings_dialog_migrate_db_message">Dadurch wird die Wallet-Datenbank auf die neue SQLite-Datenbank migriert. Die Migration ist experimentell und kann zu Datenverlust führen. Möchten Sie fortfahren?</string>
diff --git a/wallet/src/main/res/values-fr/strings.xml b/wallet/src/main/res/values-fr/strings.xml
@@ -285,6 +285,8 @@
<string name="settings_db_import_error">Erreur d\'importation de la base de données</string>
<string name="settings_db_migrate_canceled">Migration annulée</string>
<string name="settings_db_migrate_done">Migration de la base de données démarrée</string>
+ <string name="settings_db_migrate_complete">Migration SQLite de la base de données terminée</string>
+ <string name="settings_db_migrate_failed">Échec de la migration SQLite de la base de données</string>
<string name="settings_migrate_db">Migrer la base de données (expérimental !)</string>
<string name="settings_migrate_db_summary">Passer au nouveau backend SQLite</string>
<string name="settings_dialog_migrate_db_message">Cette opération migrera la base de données du portefeuille vers le nouveau backend SQLite. La migration est expérimentale et peut entraîner une perte de données. Voulez-vous continuer ?</string>
diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml
@@ -509,6 +509,8 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish
<string name="settings_db_import_summary">Restore database from file</string>
<string name="settings_db_migrate_canceled">Migration cancelled</string>
<string name="settings_db_migrate_done">Database migration started</string>
+ <string name="settings_db_migrate_complete">SQLite database migration complete</string>
+ <string name="settings_db_migrate_failed">SQLite database migration failed</string>
<string name="settings_dev_mode">Developer mode</string>
<string name="settings_dev_mode_summary">Shows more information intended for debugging</string>
<string name="settings_dialog_import_message">This operation will overwrite your existing database. Do you want to continue?</string>