taler-android

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

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:
Mwallet/src/main/java/net/taler/wallet/backend/ApiResponse.kt | 12------------
Awallet/src/main/java/net/taler/wallet/backend/Notifications.kt | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/main/MainScreen.kt | 16++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/main/MainViewModel.kt | 82++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mwallet/src/main/res/values-de/strings.xml | 2++
Mwallet/src/main/res/values-fr/strings.xml | 2++
Mwallet/src/main/res/values/strings.xml | 2++
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>