commit bc6e5b4e92a3fd19b6d5a9a6fce11434a93a0c37 parent 96e1bb098ec30a84a4ea4eab5f2eafe92dc23c56 Author: Florian Dold <dold@taler.net> Date: Wed, 26 Aug 2026 12:40:07 +0200 wallet: offer native database migration Diffstat:
12 files changed, 487 insertions(+), 95 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 @@ -47,7 +47,7 @@ class BackendManager( companion object { private const val TAG = "BackendManager" private const val TAG_CORE = "taler-wallet-embedded" - private const val REQUEST_TIMEOUT_MS = 60_000L + const val REQUEST_TIMEOUT_MS = 60_000L val json = Json { ignoreUnknownKeys = true coerceInputValues = true @@ -98,9 +98,13 @@ class BackendManager( coreRunning.set(false) } - suspend fun send(operation: String, args: JSONObject? = null): ApiResponse { + suspend fun send( + operation: String, + args: JSONObject? = null, + timeoutMs: Long? = REQUEST_TIMEOUT_MS, + ): ApiResponse { var requestId = -1 - val response = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { + suspend fun awaitResponse(): ApiResponse = suspendCancellableCoroutine { cont -> requestManager.addRequest(cont) { id -> requestId = id @@ -116,6 +120,10 @@ class BackendManager( requestManager.getAndRemoveContinuation(requestId) } } + if (timeoutMs == null) return awaitResponse() + + val response = withTimeoutOrNull(timeoutMs) { + awaitResponse() } if (response != null) return response return ApiResponse.Error( diff --git a/wallet/src/main/java/net/taler/wallet/backend/InitResponse.kt b/wallet/src/main/java/net/taler/wallet/backend/InitResponse.kt @@ -67,10 +67,16 @@ data class WalletRunConfig( data class Features( val allowHttp: Boolean = false, val enableV1Contracts: Boolean = false, - val migrateNativeDb: Boolean = false, + val useNativeDb: Boolean = false, ) } +@Serializable +data class MigrateDatabaseResponse( + val migrated: Boolean, + val databaseBackend: WalletDatabaseBackend, +) + interface InitReceiver { fun onInitReceived(init: InitResponse) fun onInitErrorReceived(error: TalerErrorInfo) @@ -96,4 +102,4 @@ enum class WalletDatabaseBackend { @SerialName("sqlite") Sqlite, -} -\ No newline at end of file +} diff --git a/wallet/src/main/java/net/taler/wallet/backend/Notifications.kt b/wallet/src/main/java/net/taler/wallet/backend/Notifications.kt @@ -77,6 +77,9 @@ sealed class NotificationPayload { data class DatabaseMaintenanceProgress( val operation: String, val phase: String, + val progressToken: String? = null, + val completionPercent: Int? = null, + val error: TalerErrorInfo? = null, ) : NotificationPayload() @Serializable(with = UnknownPayloadSerializer::class) @@ -129,4 +132,4 @@ object UnknownPayloadSerializer : KSerializer<NotificationPayload.Unknown> { jsonEncoder.encodeJsonElement(value.raw) } -} -\ No newline at end of file +} diff --git a/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt b/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt @@ -80,18 +80,40 @@ class WalletBackendApi( } } - suspend fun sendRequest(operation: String, args: JSONObject? = null): ApiResponse { - return backendManager.send(operation, args) + suspend fun migrateDatabase(progressToken: String): WalletResponse<MigrateDatabaseResponse> { + return request( + "migrateDatabase", + MigrateDatabaseResponse.serializer(), + timeoutMs = null, + ) { + put("progressToken", progressToken) + } + } + + suspend fun cancelDatabaseMigration(progressToken: String): WalletResponse<Unit> { + return request("cancelProgressToken") { + put("operation", "migrateDatabase") + put("progressToken", progressToken) + } + } + + suspend fun sendRequest( + operation: String, + args: JSONObject? = null, + timeoutMs: Long? = BackendManager.REQUEST_TIMEOUT_MS, + ): ApiResponse { + return backendManager.send(operation, args, timeoutMs) } suspend inline fun <reified T> request( operation: String, serializer: KSerializer<T>? = null, + timeoutMs: Long? = BackendManager.REQUEST_TIMEOUT_MS, noinline args: (JSONObject.() -> JSONObject)? = null, ): WalletResponse<T> = withContext(Dispatchers.Default) { val json = BackendManager.json try { - when (val response = sendRequest(operation, args?.invoke(JSONObject()))) { + when (val response = sendRequest(operation, args?.invoke(JSONObject()), timeoutMs)) { is ApiResponse.Response -> { val t: T = serializer?.let { json.decodeFromJsonElement(serializer, response.result) diff --git a/wallet/src/main/java/net/taler/wallet/main/MainActivity.kt b/wallet/src/main/java/net/taler/wallet/main/MainActivity.kt @@ -42,11 +42,13 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -122,6 +124,8 @@ class MainActivity : FragmentActivity() { val errorSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = !devMode) val authenticated by model.authenticated.collectAsState() val biometricEnabled by model.settingsManager.getBiometricLockEnabled(this).collectAsState(false) + val databaseMigrationState by model.databaseMigrationState.collectAsState() + val walletUnlocked = !biometricEnabled || authenticated val logExportLauncher = rememberLauncherForActivityResult(CreateDocument("text/plain")) { uri -> uri?.let { model.settingsManager.exportLogcat(it) } @@ -141,6 +145,12 @@ class MainActivity : FragmentActivity() { } } + LaunchedEffect(databaseMigrationState, walletUnlocked) { + if (walletUnlocked && databaseMigrationState is DatabaseMigrationState.Failed) { + errorInfo = model.consumeDatabaseMigrationFailure() + } + } + Box(Modifier.fillMaxSize()) { initError?.let { error -> WalletInitErrorScreen( @@ -202,6 +212,29 @@ class MainActivity : FragmentActivity() { } ) } + + if (walletUnlocked) when (val state = databaseMigrationState) { + DatabaseMigrationState.Prompt -> DatabaseMigrationPrompt( + onMigrate = model::migrateDatabase, + onLater = model::deferDatabaseMigration, + ) + + is DatabaseMigrationState.Migrating -> DatabaseMigrationProgressDialog( + completionPercent = state.completionPercent, + cancelling = false, + onCancel = { + model.cancelDatabaseMigration { errorInfo = it } + }, + ) + + is DatabaseMigrationState.Cancelling -> DatabaseMigrationProgressDialog( + completionPercent = state.completionPercent, + cancelling = true, + onCancel = {}, + ) + + else -> {} + } } } @@ -383,6 +416,81 @@ class MainActivity : FragmentActivity() { } @Composable +private fun DatabaseMigrationPrompt( + onMigrate: () -> Unit, + onLater: () -> Unit, +) { + AlertDialog( + onDismissRequest = onLater, + title = { Text(stringResource(R.string.wallet_db_migration_title)) }, + text = { Text(stringResource(R.string.wallet_db_migration_message)) }, + confirmButton = { + Button(onClick = onMigrate) { + Text(stringResource(R.string.wallet_db_migration_now)) + } + }, + dismissButton = { + TextButton(onClick = onLater) { + Text(stringResource(R.string.wallet_db_migration_later)) + } + }, + ) +} + +@Composable +private fun DatabaseMigrationProgressDialog( + completionPercent: Int, + cancelling: Boolean, + onCancel: () -> Unit, +) { + AlertDialog( + onDismissRequest = {}, + title = { + Text( + stringResource( + if (cancelling) { + R.string.wallet_db_migration_cancelling + } else { + R.string.wallet_db_migration_in_progress + }, + ), + ) + }, + text = { + Column { + LinearProgressIndicator( + progress = { completionPercent / 100f }, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = stringResource( + R.string.wallet_db_migration_progress, + completionPercent, + ), + modifier = Modifier.padding(top = 12.dp), + ) + } + }, + confirmButton = { + TextButton( + onClick = onCancel, + enabled = !cancelling, + ) { + Text( + stringResource( + if (cancelling) { + R.string.wallet_db_migration_cancelling + } else { + R.string.cancel + }, + ), + ) + } + }, + ) +} + +@Composable fun BiometricOverlay(onUnlock: () -> Unit) { Box( modifier = Modifier 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,7 +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 databaseMigrationState by model.databaseMigrationState.collectAsStateLifecycleAware() val devMode by model.devMode.observeAsState(false) val txResult by remember(viewMode) { val v = viewMode as? ViewMode.Transactions @@ -207,18 +207,11 @@ fun MainScreen( } 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 -> {} + LaunchedEffect(databaseMigrationState) { + if (databaseMigrationState == DatabaseMigrationState.Complete) { + snackbarHostState.showSnackbar(migrateCompleteMsg) + model.acknowledgeDatabaseMigrationComplete() } - model.resetDbMigrationStatus() } BackHandler(selectionMode || (tab == MainTab.ASSETS && viewMode !is ViewMode.Assets)) { @@ -439,4 +432,4 @@ private fun onTransactionClicked( else -> showTxDetails() } -} -\ No newline at end of file +} diff --git a/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt @@ -35,9 +35,11 @@ import net.taler.wallet.accounts.AccountManager import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.NotificationPayload import net.taler.wallet.backend.NotificationReceiver +import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.InitReceiver import net.taler.wallet.backend.WalletBackendApi +import net.taler.wallet.backend.WalletDatabaseBackend import net.taler.wallet.backend.WalletRunConfig import net.taler.wallet.backend.WalletRunConfig.Features import net.taler.wallet.backend.WalletRunConfig.Testing @@ -56,9 +58,10 @@ import net.taler.wallet.withdraw.WithdrawManager import net.taler.wallet.BuildConfig import net.taler.wallet.NetworkManager import net.taler.wallet.backend.InitResponse -import net.taler.wallet.backend.WalletDatabaseBackend +import net.taler.wallet.backend.MigrateDatabaseResponse import net.taler.wallet.donau.DonauManager import net.taler.wallet.tokens.TokenManager +import java.util.UUID const val TAG = "taler-wallet" const val OBSERVABILITY_LIMIT = 100 @@ -79,8 +82,8 @@ class MainViewModel( private set var merchantVersion: String? = null private set - var databaseBackend: WalletDatabaseBackend? = null - private set + private val mDatabaseBackend = MutableStateFlow<WalletDatabaseBackend?>(null) + val databaseBackend: StateFlow<WalletDatabaseBackend?> = mDatabaseBackend @set:Synchronized private var walletConfig = WalletRunConfig( @@ -90,6 +93,7 @@ class MainViewModel( ), features = Features( enableV1Contracts = true, + useNativeDb = true, ), logLevel = if (devMode.value == true) "TRACE" else "INFO", ) @@ -128,8 +132,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 + private val mDatabaseMigrationState = + MutableStateFlow<DatabaseMigrationState>(DatabaseMigrationState.None) + val databaseMigrationState: StateFlow<DatabaseMigrationState> = mDatabaseMigrationState fun startWallet() { api.startWallet() @@ -148,7 +153,13 @@ class MainViewModel( walletVersionHash = init.versionInfo.implementationGitHash exchangeVersion = init.versionInfo.exchange merchantVersion = init.versionInfo.merchant - databaseBackend = init.databaseBackend + mDatabaseBackend.value = init.databaseBackend + if ( + init.databaseBackend == WalletDatabaseBackend.IndexedDB && + mDatabaseMigrationState.value == DatabaseMigrationState.None + ) { + mDatabaseMigrationState.value = DatabaseMigrationState.Prompt + } mInitError.value = null } @@ -198,13 +209,7 @@ class MainViewModel( } 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 - } - } + updateDatabaseMigrationProgress(payload) } else -> {} @@ -291,29 +296,87 @@ class MainViewModel( } } - /** - * Enables the experimental migration of the wallet database to the new - * native SQLite backend. This may result in data loss and cannot be undone. - */ - fun enableMigrateNativeDb(onError: (error: TalerErrorInfo) -> Unit) { + fun offerDatabaseMigration() { + if ( + mDatabaseBackend.value == WalletDatabaseBackend.IndexedDB && + mDatabaseMigrationState.value !is DatabaseMigrationState.Migrating && + mDatabaseMigrationState.value !is DatabaseMigrationState.Cancelling + ) { + mDatabaseMigrationState.value = DatabaseMigrationState.Prompt + } + } + + fun deferDatabaseMigration() { + if ( + mDatabaseMigrationState.value == DatabaseMigrationState.Prompt || + mDatabaseMigrationState.value is DatabaseMigrationState.Failed + ) { + mDatabaseMigrationState.value = DatabaseMigrationState.Deferred + } + } + + fun migrateDatabase() { + if (mDatabaseBackend.value != WalletDatabaseBackend.IndexedDB) return + if ( + mDatabaseMigrationState.value is DatabaseMigrationState.Migrating || + mDatabaseMigrationState.value is DatabaseMigrationState.Cancelling + ) return + + val progressToken = UUID.randomUUID().toString() + mDatabaseMigrationState.value = DatabaseMigrationState.Migrating(progressToken, 0) + viewModelScope.launch { - val config = walletConfig.copy( - features = walletConfig.features?.copy( - migrateNativeDb = true, - ) ?: Features( - migrateNativeDb = true, - ), - ) + api.migrateDatabase(progressToken) + .onSuccess { response -> + mDatabaseBackend.value = response.databaseBackend + mDatabaseMigrationState.value = response.toDatabaseMigrationState() + } + .onError { error -> + mDatabaseMigrationState.value = + mDatabaseMigrationState.value.withMigrationError(error) + } + } + } - api.setWalletConfig(config) - .onSuccess { - walletConfig = config - }.onError(onError) + fun cancelDatabaseMigration(onError: (error: TalerErrorInfo) -> Unit) { + val state = mDatabaseMigrationState.value as? DatabaseMigrationState.Migrating ?: return + mDatabaseMigrationState.value = DatabaseMigrationState.Cancelling( + progressToken = state.progressToken, + completionPercent = state.completionPercent, + ) + viewModelScope.launch { + api.cancelDatabaseMigration(state.progressToken).onError { error -> + val current = mDatabaseMigrationState.value + if ( + current is DatabaseMigrationState.Cancelling && + current.progressToken == state.progressToken + ) { + mDatabaseMigrationState.value = DatabaseMigrationState.Migrating( + progressToken = state.progressToken, + completionPercent = current.completionPercent, + ) + onError(error) + } + } } } - fun resetDbMigrationStatus() { - mDbMigrationStatus.value = DbMigrationStatus.None + fun acknowledgeDatabaseMigrationComplete() { + if (mDatabaseMigrationState.value == DatabaseMigrationState.Complete) { + mDatabaseMigrationState.value = DatabaseMigrationState.None + } + } + + fun consumeDatabaseMigrationFailure(): TalerErrorInfo? { + val state = mDatabaseMigrationState.value as? DatabaseMigrationState.Failed ?: return null + mDatabaseMigrationState.value = DatabaseMigrationState.Deferred + return state.error + } + + private fun updateDatabaseMigrationProgress( + payload: NotificationPayload.DatabaseMaintenanceProgress, + ) { + mDatabaseMigrationState.value = mDatabaseMigrationState.value.withProgress(payload) } fun showObservabilityLog() { @@ -347,8 +410,64 @@ sealed class AmountResult { data object InvalidAmount : AmountResult() } -enum class DbMigrationStatus { - None, - Complete, - Failed, -} -\ No newline at end of file +sealed interface DatabaseMigrationState { + data object None : DatabaseMigrationState + data object Prompt : DatabaseMigrationState + data object Deferred : DatabaseMigrationState + data class Migrating( + val progressToken: String, + val completionPercent: Int, + ) : DatabaseMigrationState + + data class Cancelling( + val progressToken: String, + val completionPercent: Int, + ) : DatabaseMigrationState + + data object Complete : DatabaseMigrationState + data class Failed(val error: TalerErrorInfo) : DatabaseMigrationState +} + +internal fun DatabaseMigrationState.withProgress( + payload: NotificationPayload.DatabaseMaintenanceProgress, +): DatabaseMigrationState { + if (payload.operation != "indexeddb-to-native-migration") return this + val percent = payload.completionPercent?.coerceIn(0, 100) + + return when (this) { + is DatabaseMigrationState.Migrating -> { + if (payload.progressToken != progressToken) this + else copy(completionPercent = percent ?: completionPercent) + } + + is DatabaseMigrationState.Cancelling -> { + if (payload.progressToken != progressToken) this + else copy(completionPercent = percent ?: completionPercent) + } + + else -> this + } +} + +internal fun DatabaseMigrationState.withMigrationError( + error: TalerErrorInfo, +): DatabaseMigrationState = + if ( + this is DatabaseMigrationState.Cancelling && + error.code == TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED + ) { + DatabaseMigrationState.Deferred + } else { + DatabaseMigrationState.Failed(error) + } + +internal fun MigrateDatabaseResponse.toDatabaseMigrationState(): DatabaseMigrationState = + if (databaseBackend == WalletDatabaseBackend.Sqlite) { + DatabaseMigrationState.Complete + } else { + DatabaseMigrationState.Failed( + TalerErrorInfo.makeCustomError( + message = "Database migration completed without switching to SQLite", + ), + ) + } diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsScreen.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsScreen.kt @@ -105,8 +105,6 @@ fun SettingsScreen( val testRunningMessage = stringResource(R.string.settings_test_running) val resetDoneMessage = stringResource(R.string.settings_alert_reset_done) val resetCanceledMessage = stringResource(R.string.settings_alert_reset_canceled) - val migrateDoneMessage = stringResource(R.string.settings_db_migrate_done) - val migrateCanceledMessage = stringResource(R.string.settings_db_migrate_canceled) val biometricAuthUnavailableMessage = stringResource(R.string.biometric_auth_unavailable) val scope = rememberCoroutineScope() val settingsManager = model.settingsManager @@ -115,6 +113,7 @@ fun SettingsScreen( val biometricLockEnabled by settingsManager.getBiometricLockEnabled(context).collectAsState(false) val devModeEnabled by settingsManager.getDevModeEnabled(context).collectAsState(false) val withdrawTestStatus by withdrawManager.withdrawTestStatus.collectAsState() + val databaseBackend by model.databaseBackend.collectAsState() val walletVersion = model.walletVersion val walletVersionHash = model.walletVersionHash?.take(7) @@ -270,22 +269,11 @@ fun SettingsScreen( } ) - if (model.databaseBackend != WalletDatabaseBackend.Sqlite) SettingsItem( + if (databaseBackend == WalletDatabaseBackend.IndexedDB) SettingsItem( title = stringResource(R.string.settings_migrate_db), summary = stringResource(R.string.settings_migrate_db_summary), icon = Icons.Default.Memory, - onClick = { - MaterialAlertDialogBuilder(context) - .setMessage(R.string.settings_dialog_migrate_db_message) - .setNegativeButton(R.string.settings_migrate_db) { _, _ -> - model.enableMigrateNativeDb { onShowError(it) } - scope.launch { snackbarHostState.showSnackbar(migrateDoneMessage) } - } - .setPositiveButton(R.string.cancel) { _, _ -> - scope.launch { snackbarHostState.showSnackbar(migrateCanceledMessage) } - } - .show() - } + onClick = model::offerDatabaseMigration, ) SettingsItem( diff --git a/wallet/src/main/res/values-de/strings.xml b/wallet/src/main/res/values-de/strings.xml @@ -181,13 +181,16 @@ <string name="send_peer_purpose">Buchungsvermerk</string> <string name="exchange_list_select">Zahlungsdienstleister auswählen</string> <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> + <string name="settings_migrate_db">Wallet-Datenbank aktualisieren</string> + <string name="settings_migrate_db_summary">Auf die schnellere SQLite-Datenbank umstellen</string> + <string name="wallet_db_migration_title">Wallet-Datenbank aktualisieren?</string> + <string name="wallet_db_migration_message">Die Wallet kann eine schnellere Datenbank verwenden. Die migrierten Daten werden vor dem Wechsel überprüft. Wenn die Migration fehlschlägt, wird die bisherige Datenbank weiterverwendet.</string> + <string name="wallet_db_migration_now">Jetzt migrieren</string> + <string name="wallet_db_migration_later">Später</string> + <string name="wallet_db_migration_in_progress">Wallet-Datenbank wird aktualisiert</string> + <string name="wallet_db_migration_cancelling">Wird abgebrochen…</string> + <string name="wallet_db_migration_progress">%1$d%% abgeschlossen</string> <string name="error_broken_uri">Fehler: Dieser Taler-Link funktioniert (zurzeit) nicht.</string> <string name="settings_alert_import_canceled">Import abgebrochen</string> <string name="import_db">Importieren</string> diff --git a/wallet/src/main/res/values-fr/strings.xml b/wallet/src/main/res/values-fr/strings.xml @@ -285,13 +285,16 @@ <string name="settings_alert_import_canceled">Importation annulée</string> <string name="observability_hide_json">Cacher le JSON</string> <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> + <string name="settings_migrate_db">Mettre à niveau la base de données</string> + <string name="settings_migrate_db_summary">Passer à la base de données SQLite plus rapide</string> + <string name="wallet_db_migration_title">Mettre à niveau la base de données ?</string> + <string name="wallet_db_migration_message">Le portefeuille peut utiliser une base de données plus rapide. Les données migrées seront vérifiées avant le changement. En cas d’échec, la base de données existante continuera d’être utilisée.</string> + <string name="wallet_db_migration_now">Migrer maintenant</string> + <string name="wallet_db_migration_later">Plus tard</string> + <string name="wallet_db_migration_in_progress">Mise à niveau de la base de données</string> + <string name="wallet_db_migration_cancelling">Annulation…</string> + <string name="wallet_db_migration_progress">%1$d %% terminé</string> <string name="settings_logcat_error">Erreur d\'exportation du journal</string> <string name="send_deposit_host">Banque en devise locale</string> <string name="send_deposit_no_methods_error">Aucune méthode de virement n\'est prise en charge</string> diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -512,10 +512,7 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="settings_db_import_message">Importing database, please wait until confirmation</string> <string name="settings_db_import_success">Database imported from file</string> <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> @@ -528,9 +525,15 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="settings_logcat_error">Error exporting log</string> <string name="settings_logcat_success">Log exported to file</string> <string name="settings_logcat_summary">Save internal log</string> - <string name="settings_migrate_db">Migrate database (experimental!)</string> - <string name="settings_migrate_db_summary">Switch to the new SQLite database backend</string> - <string name="settings_dialog_migrate_db_message">This will migrate the wallet database to the new SQLite backend. The migration is experimental and may result in data loss. Do you want to continue?</string> + <string name="settings_migrate_db">Upgrade wallet database</string> + <string name="settings_migrate_db_summary">Switch to the faster SQLite database backend</string> + <string name="wallet_db_migration_title">Upgrade wallet database?</string> + <string name="wallet_db_migration_message">The wallet can use a faster database. It will verify the migrated data before switching, and continue using the existing database if migration fails.</string> + <string name="wallet_db_migration_now">Migrate now</string> + <string name="wallet_db_migration_later">Later</string> + <string name="wallet_db_migration_in_progress">Upgrading wallet database</string> + <string name="wallet_db_migration_cancelling">Cancelling…</string> + <string name="wallet_db_migration_progress">%1$d%% complete</string> <string name="settings_stats">Performance stats</string> <string name="settings_stats_summary">View top time-consuming operations</string> <string name="settings_reset">Reset Wallet (dangerous!)</string> diff --git a/wallet/src/test/java/net/taler/wallet/main/DatabaseMigrationTest.kt b/wallet/src/test/java/net/taler/wallet/main/DatabaseMigrationTest.kt @@ -0,0 +1,140 @@ +/* + * 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.main + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import net.taler.wallet.backend.MigrateDatabaseResponse +import net.taler.wallet.backend.NotificationPayload +import net.taler.wallet.backend.TalerErrorCode +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.backend.WalletDatabaseBackend +import net.taler.wallet.backend.WalletRunConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class DatabaseMigrationTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun nativeDatabasePreferenceIsSerialized() { + val config = WalletRunConfig( + features = WalletRunConfig.Features( + enableV1Contracts = true, + useNativeDb = true, + ), + logLevel = "INFO", + ) + + val encoded = json.encodeToString(config) + + assertTrue(encoded.contains("\"useNativeDb\":true")) + assertFalse(encoded.contains("migrateNativeDb")) + } + + @Test + fun migrationResponseIsDecoded() { + val response = json.decodeFromString( + MigrateDatabaseResponse.serializer(), + """{"migrated":true,"databaseBackend":"sqlite"}""", + ) + + assertTrue(response.migrated) + assertEquals(WalletDatabaseBackend.Sqlite, response.databaseBackend) + assertEquals(DatabaseMigrationState.Complete, response.toDatabaseMigrationState()) + } + + @Test + fun maintenanceProgressIsDecodedWithOptionalFields() { + val payload = json.decodeFromString( + NotificationPayload.serializer(), + """ + { + "type":"database-maintenance-progress", + "operation":"indexeddb-to-native-migration", + "phase":"copy", + "progressToken":"migration-1", + "completionPercent":42, + "completedSteps":4, + "totalSteps":10 + } + """.trimIndent(), + ) as NotificationPayload.DatabaseMaintenanceProgress + + assertEquals("migration-1", payload.progressToken) + assertEquals(42, payload.completionPercent) + assertEquals(null, payload.error) + } + + @Test + fun progressOnlyAcceptsTheActiveMigrationToken() { + val state = DatabaseMigrationState.Migrating("active", 10) + val stale = NotificationPayload.DatabaseMaintenanceProgress( + operation = "indexeddb-to-native-migration", + phase = "copy", + progressToken = "stale", + completionPercent = 75, + ) + val unrelated = stale.copy( + operation = "indexeddb-fixup", + progressToken = "active", + ) + val active = stale.copy( + progressToken = "active", + completionPercent = 150, + ) + + assertSame(state, state.withProgress(stale)) + assertSame(state, state.withProgress(unrelated)) + assertEquals( + DatabaseMigrationState.Migrating("active", 100), + state.withProgress(active), + ) + } + + @Test + fun cancellationErrorDefersWithoutReportingFailure() { + val state = DatabaseMigrationState.Cancelling("active", 35) + val cancellation = TalerErrorInfo( + code = TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED, + ) + + assertEquals(DatabaseMigrationState.Deferred, state.withMigrationError(cancellation)) + } + + @Test + fun aCompletedMigrationWinsTheCancellationRace() { + val response = MigrateDatabaseResponse( + migrated = true, + databaseBackend = WalletDatabaseBackend.Sqlite, + ) + + assertEquals(DatabaseMigrationState.Complete, response.toDatabaseMigrationState()) + } + + @Test + fun ordinaryMigrationErrorsRemainVisible() { + val state = DatabaseMigrationState.Migrating("active", 35) + val error = TalerErrorInfo(code = TalerErrorCode.WALLET_DB_UNAVAILABLE) + + assertEquals(DatabaseMigrationState.Failed(error), state.withMigrationError(error)) + } +}