commit 089541c0336ece82039926c3b504d2d9f1f43d1d parent 2f9853c5eeecf7181ca3a386c6d5870e09a70eed Author: Iván Ávalos <avalos@disroot.org> Date: Thu, 22 Oct 2026 17:36:16 +0200 [wallet] implement progress notifications Diffstat:
20 files changed, 471 insertions(+), 41 deletions(-)
diff --git a/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt b/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt @@ -4,6 +4,7 @@ import android.net.Uri import android.util.Log import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -19,12 +20,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.GlobalScaffold import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.RetryScreen import net.taler.wallet.main.MainViewModel import net.taler.wallet.main.TAG import net.taler.wallet.payment.PayStatus import net.taler.wallet.refund.RefundStatus +import net.taler.wallet.transactions.TransactionMajorState import java.io.IOException import java.net.HttpURLConnection import java.net.URL @@ -81,6 +84,10 @@ fun HandleUriScreen( when { action.startsWith("pay/", ignoreCase = true) -> { model.paymentManager.preparePay(u2)?.let { transactionId -> + model.paymentManager.waitForTransaction( + transactionId, + TransactionMajorState.Dialog, + ) if (model.transactionManager.selectTransaction(transactionId)) { onNavigate(WalletDestination.TransactionPayment(promptMode = true), true) } @@ -170,12 +177,22 @@ fun HandleUriScreen( } } - Box(Modifier.fillMaxSize()) { - if (networkStatus == true) { - LoadingScreen() - } else { - RetryScreen { - processTalerUri() + GlobalScaffold( + model = model, + modifier = Modifier.fillMaxSize(), + onNavigateBack = onNavigateBack, + ) { paddingValues -> + Box( + Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + if (networkStatus == true) { + LoadingScreen() + } else { + RetryScreen { + processTalerUri() + } } } } diff --git a/wallet/src/main/java/net/taler/wallet/backend/Notifications.kt b/wallet/src/main/java/net/taler/wallet/backend/Notifications.kt @@ -31,6 +31,7 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import net.taler.common.RelativeTime import net.taler.wallet.events.ObservabilityEvent import net.taler.wallet.transactions.TransactionState @@ -82,6 +83,24 @@ sealed class NotificationPayload { val error: TalerErrorInfo? = null, ) : NotificationPayload() + @Serializable + @SerialName("request-progress-phase") + data class RequestProgressPhase( + val operation: String, + val progressToken: String, + val phase: ProgressPhase, + ) : NotificationPayload() + + @Serializable + @SerialName("request-progress-error") + data class RequestProgressError( + val operation: String, + val progressToken: String, + val error: TalerErrorInfo, + val nextRetryDelay: RelativeTime? = null, + val retryCounter: Int? = null, + ) : NotificationPayload() + @Serializable(with = UnknownPayloadSerializer::class) data class Unknown( val type: String? = null, @@ -89,6 +108,18 @@ sealed class NotificationPayload { ) : NotificationPayload() } +@Serializable +enum class ProgressPhase { + @SerialName("delayed") + Delayed, + + @SerialName("stalled") + Stalled, + + @SerialName("done") + Done, +} + object NotificationPayloadSerializer : JsonContentPolymorphicSerializer<NotificationPayload>(NotificationPayload::class) { @@ -101,6 +132,8 @@ object NotificationPayloadSerializer : "task-observability-event" -> NotificationPayload.TaskObservabilityEvent.serializer() "request-observability-event" -> NotificationPayload.RequestObservabilityEvent.serializer() "database-maintenance-progress" -> NotificationPayload.DatabaseMaintenanceProgress.serializer() + "request-progress-phase" -> NotificationPayload.RequestProgressPhase.serializer() + "request-progress-error" -> NotificationPayload.RequestProgressError.serializer() else -> NotificationPayload.Unknown.serializer() } diff --git a/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt b/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt @@ -91,8 +91,12 @@ class WalletBackendApi( } suspend fun cancelDatabaseMigration(progressToken: String): WalletResponse<Unit> { + return cancelProgressToken("migrateDatabase", progressToken) + } + + suspend fun cancelProgressToken(operation: String, progressToken: String): WalletResponse<Unit> { return request("cancelProgressToken") { - put("operation", "migrateDatabase") + put("operation", operation) put("progressToken", progressToken) } } @@ -109,11 +113,18 @@ class WalletBackendApi( operation: String, serializer: KSerializer<T>? = null, timeoutMs: Long? = BackendManager.REQUEST_TIMEOUT_MS, + progressToken: String? = null, noinline args: (JSONObject.() -> JSONObject)? = null, ): WalletResponse<T> = withContext(Dispatchers.Default) { val json = BackendManager.json try { - when (val response = sendRequest(operation, args?.invoke(JSONObject()), timeoutMs)) { + val requestArgs = if (args != null || progressToken != null) { + JSONObject().apply { + args?.invoke(this) + progressToken?.let { put("progressToken", it) } + } + } else null + when (val response = sendRequest(operation, requestArgs, timeoutMs)) { is ApiResponse.Response -> { val t: T = serializer?.let { json.decodeFromJsonElement(serializer, response.result) diff --git a/wallet/src/main/java/net/taler/wallet/compose/GlobalScaffold.kt b/wallet/src/main/java/net/taler/wallet/compose/GlobalScaffold.kt @@ -16,7 +16,11 @@ package net.taler.wallet.compose -import androidx.compose.foundation.background +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -25,9 +29,8 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBars import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -35,10 +38,8 @@ import androidx.compose.material.icons.filled.BugReport import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults -import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState @@ -48,8 +49,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import net.taler.wallet.R import net.taler.wallet.main.MainViewModel @@ -77,13 +76,19 @@ fun GlobalScaffold( val devMode = model?.devMode?.observeAsState(false) val online = model?.networkManager?.networkStatus?.observeAsState(true) + val progressState = model?.requestProgress?.collectAsStateLifecycleAware() + val requestProgress = progressState?.value + val offline = online?.value == false + val showStatusBanner = offline || (online?.value != false && requestProgress != null) Scaffold( modifier = modifier .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - Column { + Column( + modifier = if (title == null) Modifier.statusBarsPadding() else Modifier, + ) { if (title != null) { TopAppBar( title = title, @@ -121,15 +126,16 @@ fun GlobalScaffold( tabs() - if (online != null && !online.value) Text( - text = stringResource(R.string.offline_banner), - textAlign = TextAlign.Center, - modifier = Modifier - .background(MaterialTheme.colorScheme.errorContainer) - .padding(8.dp) - .fillMaxWidth(), - color = MaterialTheme.colorScheme.onErrorContainer, - ) + AnimatedVisibility( + visible = showStatusBanner, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + NetworkStatusBanner( + offline = offline, + progress = if (offline) null else requestProgress, + ) + } } }, floatingActionButton = floatingActionButton, diff --git a/wallet/src/main/java/net/taler/wallet/compose/NetworkStatusBanner.kt b/wallet/src/main/java/net/taler/wallet/compose/NetworkStatusBanner.kt @@ -0,0 +1,175 @@ +/* + * 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.compose + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.taler.wallet.R +import net.taler.wallet.backend.ProgressPhase +import net.taler.wallet.main.RequestProgress +import net.taler.wallet.ui.theme.TalerTheme + +@Composable +fun NetworkStatusBanner( + offline: Boolean, + progress: RequestProgress?, +) { + when { + offline -> StatusBanner( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) { + Text(stringResource(R.string.offline_banner)) + } + + progress != null -> StatusBanner( + containerColor = TalerTheme.extraColors.warningContainer, + contentColor = TalerTheme.extraColors.onWarningContainer, + ) { + Text(stringResource(R.string.request_progress_slow)) + RetryStatus(progress) + } + } +} + +@Composable +private fun StatusBanner( + containerColor: Color, + contentColor: Color, + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalContentColor provides contentColor, + LocalTextStyle provides MaterialTheme.typography.bodyLarge, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(containerColor) + .animateContentSize() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + content() + } + } +} + +@Composable +private fun RetryStatus(progress: RequestProgress) { + val text = when { + progress.retrying -> R.string.request_progress_retrying + progress.phase == ProgressPhase.Stalled -> R.string.request_progress_waiting + else -> return + } + Row( + modifier = Modifier.padding(top = 8.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = LocalContentColor.current, + ) + Spacer(Modifier.size(6.dp)) + Text( + text = stringResource(text), + style = MaterialTheme.typography.labelMediumEmphasized, + ) + } +} + +@Preview +@Composable +private fun NetworkStatusBannerOfflinePreview() { + TalerTheme { + TalerSurface { + NetworkStatusBanner(offline = true, progress = null) + } + } +} + +@Preview +@Composable +private fun NetworkStatusBannerSlowPreview() { + TalerTheme { + TalerSurface { + NetworkStatusBanner( + offline = false, + progress = RequestProgress( + operation = "waitTransactionState", + progressToken = "txn:payment:1", + phase = ProgressPhase.Delayed, + ), + ) + } + } +} + +@Preview +@Composable +private fun NetworkStatusBannerStalledPreview() { + TalerTheme { + TalerSurface { + NetworkStatusBanner( + offline = false, + progress = RequestProgress( + operation = "waitTransactionState", + progressToken = "txn:payment:1", + phase = ProgressPhase.Stalled, + ), + ) + } + } +} + +@Preview +@Composable +private fun NetworkStatusBannerRetryingPreview() { + TalerTheme { + TalerSurface { + NetworkStatusBanner( + offline = false, + progress = RequestProgress( + operation = "waitTransactionState", + progressToken = "txn:payment:1", + phase = ProgressPhase.Stalled, + retrying = true, + ), + ) + } + } +} diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt @@ -74,7 +74,11 @@ class DepositManager( suspend fun checkDepositFees(paytoUri: String, amount: Amount): CheckDepositResult { val spec = getCurrencySpec(amount.currency) var response: CheckDepositResult = CheckDepositResult.None - when (val res = api.request("checkDeposit", CheckDepositResponse.serializer()) { + when (val res = api.request( + "checkDeposit", + CheckDepositResponse.serializer(), + progressToken = paytoUri, + ) { put("depositPaytoUri", paytoUri) put("amount", amount.toJSONString()) }) { @@ -145,7 +149,11 @@ class DepositManager( mDepositState.value = DepositState.MakingDeposit scope.launch { - api.request("createDepositGroup", CreateDepositGroupResponse.serializer()) { + api.request( + "createDepositGroup", + CreateDepositGroupResponse.serializer(), + progressToken = paytoUri, + ) { put("depositPaytoUri", paytoUri) put("amount", amount.toJSONString()) }.onError { diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt @@ -196,7 +196,11 @@ class ExchangeManager( suspend fun getExchangeTos( exchangeBaseUrl: String, language: String? = null, - ): WalletResponse<TosResponse> = api.request("getExchangeTos", TosResponse.serializer()) { + ): WalletResponse<TosResponse> = api.request( + "getExchangeTos", + TosResponse.serializer(), + progressToken = exchangeBaseUrl, + ) { language?.let { put("acceptLanguage", it) } put("exchangeBaseUrl", exchangeBaseUrl) }.onError { error -> diff --git a/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import net.taler.common.Amount import net.taler.common.AmountParserException @@ -158,6 +159,9 @@ class MainViewModel( private val mViewMode = MutableStateFlow(ViewMode()) val viewMode: StateFlow<ViewMode> = mViewMode + private val mRequestProgress = MutableStateFlow<RequestProgress?>(null) + val requestProgress: StateFlow<RequestProgress?> = mRequestProgress + private val mInitError = MutableStateFlow<TalerErrorInfo?>(null) val initError: StateFlow<TalerErrorInfo?> = mInitError @@ -232,6 +236,14 @@ class MainViewModel( updateDatabaseMigrationProgress(payload) } + is NotificationPayload.RequestProgressPhase -> { + mRequestProgress.update { it.withPhase(payload) } + } + + is NotificationPayload.RequestProgressError -> { + mRequestProgress.update { it.withError(payload) } + } + else -> {} } } diff --git a/wallet/src/main/java/net/taler/wallet/main/RequestProgress.kt b/wallet/src/main/java/net/taler/wallet/main/RequestProgress.kt @@ -0,0 +1,52 @@ +/* + * 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 net.taler.wallet.backend.NotificationPayload +import net.taler.wallet.backend.ProgressPhase + +data class RequestProgress( + val operation: String, + val progressToken: String, + val phase: ProgressPhase, + val retrying: Boolean = false, +) + +internal fun RequestProgress?.withPhase( + payload: NotificationPayload.RequestProgressPhase, +): RequestProgress? { + if (payload.phase == ProgressPhase.Done) { + return if (this?.progressToken == payload.progressToken) null else this + } + return if (this?.progressToken == payload.progressToken) { + copy(phase = payload.phase) + } else { + RequestProgress( + operation = payload.operation, + progressToken = payload.progressToken, + phase = payload.phase, + ) + } +} + +internal fun RequestProgress?.withError( + payload: NotificationPayload.RequestProgressError, +): RequestProgress? = if (this?.progressToken == payload.progressToken) { + copy(retrying = true) +} else { + this +} diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt @@ -47,6 +47,7 @@ import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDeta import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDetail.PaymentPossible import net.taler.wallet.transactions.TransactionMajorState import net.taler.wallet.transactions.TransactionMinorState +import net.taler.wallet.transactions.TransactionState sealed class PayStatus( open val transactionId: String? = null, @@ -169,7 +170,11 @@ class PaymentManager( currentTransactionId = null mPayStatus.value = PayStatus.Loading var transactionId: String? = null - api.request("preparePayForUriV2", PreparePayV2Response.serializer()) { + api.request( + "preparePayForUriV2", + PreparePayV2Response.serializer(), + progressToken = url, + ) { put("talerPayUri", url) }.onError { handleError("preparePayForUriV2", it) @@ -180,6 +185,29 @@ class PaymentManager( return transactionId } + fun waitForTransaction(transactionId: String, major: TransactionMajorState) { + scope.launch { + api.request( + "waitTransactionState", + WaitTransactionStateResponse.serializer(), + timeoutMs = null, + progressToken = transactionId, + ) { + put("transactionId", transactionId) + put("txState", JSONObject(BackendManager.json.encodeToString(TransactionState(major)))) + put("bailStates", "final") + }.onError { + Log.d(TAG, "waitTransactionState failed: $it") + } + } + } + + fun cancelWaitForTransaction(transactionId: String) { + scope.launch { + api.cancelProgressToken("waitTransactionState", transactionId) + } + } + @UiThread fun getPaymentChoices( transactionId: String, @@ -189,7 +217,10 @@ class PaymentManager( startNewAttempt { // Fresh payments start at a loading prompt. mPayStatus.value = PayStatus.Loading - when (val response = api.request("getChoicesForPayment", GetChoicesForPaymentResponse.serializer()) { + when (val response = api.request( + "getChoicesForPayment", + GetChoicesForPaymentResponse.serializer(), + ) { put("transactionId", transactionId) }) { is WalletResponse.Error -> { @@ -198,6 +229,7 @@ class PaymentManager( // The proposal is not downloaded yet; keep loading until it is. Log.d(TAG, "getChoicesForPayment not ready yet: $error") } else { + cancelWaitForTransaction(transactionId) handleError("getChoicesForPayment", error, transactionId) } } @@ -266,6 +298,7 @@ class PaymentManager( ), ) + cancelWaitForTransaction(transactionId) onSuccess() } } @@ -281,13 +314,21 @@ class PaymentManager( currentTransactionId = transactionId startNewAttempt { mPayStatus.postValue(PayStatus.Loading) - api.request("confirmPay", ConfirmPayResult.serializer()) { + api.request( + "confirmPay", + ConfirmPayResult.serializer(), + ) { choiceIndex?.let { put("choiceIndex", it) } put("transactionId", transactionId) put("useDonau", useDonau) }.onError { handleError("confirmPay", it, transactionId) }.onSuccess { response -> + val id = when (response) { + is ConfirmPayResult.Done -> response.transactionId + is ConfirmPayResult.Pending -> response.transactionId + } + waitForTransaction(id, TransactionMajorState.Done) mPayStatus.postValue(when (response) { is ConfirmPayResult.Done -> { currentTransactionId = null @@ -336,7 +377,11 @@ class PaymentManager( currentTransactionId = null mPayStatus.value = PayStatus.Loading startNewAttempt { - when (val response = api.request("checkPayForTemplate", CheckPayTemplateResponse.serializer()) { + when (val response = api.request( + "checkPayForTemplate", + CheckPayTemplateResponse.serializer(), + progressToken = url, + ) { put("talerPayTemplateUri", url) }) { is WalletResponse.Error -> handleError("checkPayForTemplate", response.error) @@ -368,7 +413,11 @@ class PaymentManager( // attempt before a form can be submitted. mPayStatus.value = PayStatus.Loading var transactionId: String? = null - api.request("preparePayForTemplateV2", PreparePayV2Response.serializer()) { + api.request( + "preparePayForTemplateV2", + PreparePayV2Response.serializer(), + progressToken = url, + ) { put("talerPayTemplateUri", url) put("templateParams", JSONObject(BackendManager.json.encodeToString(params))) }.onError { @@ -376,6 +425,7 @@ class PaymentManager( }.onSuccess { response -> currentTransactionId = response.transactionId transactionId = response.transactionId + waitForTransaction(response.transactionId, TransactionMajorState.Dialog) } return transactionId } diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt @@ -26,6 +26,7 @@ import net.taler.common.ContractTerms import net.taler.wallet.R import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.donau.DonauInfo +import net.taler.wallet.transactions.TransactionState import net.taler.wallet.payment.InsufficientBalanceHint.AgeRestricted import net.taler.wallet.payment.InsufficientBalanceHint.ExchangeMissingGlobalFees import net.taler.wallet.payment.InsufficientBalanceHint.FeesNotCovered @@ -349,3 +350,9 @@ sealed class ConfirmPayResult { val lastError: TalerErrorInfo? = null, ) : ConfirmPayResult() } + +@Serializable +data class WaitTransactionStateResponse( + val matched: String? = null, + val txState: TransactionState? = null, +) diff --git a/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt @@ -171,9 +171,10 @@ fun TransactionPaymentPrompt( is PayStatus.None, is PayStatus.Loading, is PayStatus.Prepared, - is PayStatus.Checked -> LoadingScreen() + is PayStatus.Checked -> LoadingScreen(modifier) is PayStatus.Choices -> PromptPaymentComposable( status = status, + modifier = modifier, onConfirm = { index, useDonau -> onConfirmPay(index, useDonau) }, diff --git a/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt b/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt @@ -131,7 +131,11 @@ class PeerManager( ) } - api.request("checkPeerPullCredit", CheckPeerPullCreditResponse.serializer()) { + api.request( + "checkPeerPullCredit", + CheckPeerPullCreditResponse.serializer(), + progressToken = "checkPeerPullCredit", + ) { put("restrictScope", JSONObject(BackendManager.json.encodeToString(scopeInfo))) put("amount", amount.toJSONString()) }.onSuccess { @@ -187,7 +191,11 @@ class PeerManager( maxDepositAmountRaw = max?.rawAmount, ) - api.request("checkPeerPushDebitV2", CheckPeerPushDebitResponse.serializer()) { + api.request( + "checkPeerPushDebitV2", + CheckPeerPushDebitResponse.serializer(), + progressToken = "checkPeerPushDebitV2", + ) { exchangeBaseUrl?.let { put("exchangeBaseUrl", it) } restrictScope?.let { put("restrictScope", JSONObject(BackendManager.json.encodeToString(it))) } put("amount", amount.toJSONString()) @@ -272,7 +280,11 @@ class PeerManager( ) { _incomingPullState.value = IncomingChecking scope.launch(Dispatchers.IO) { - api.request("preparePeerPullDebit", PreparePeerPullDebitResponse.serializer()) { + api.request( + "preparePeerPullDebit", + PreparePeerPullDebitResponse.serializer(), + progressToken = talerUri ?: transactionId ?: "preparePeerPullDebit", + ) { talerUri?.let { put("talerUri", talerUri) } transactionId?.let { put("transactionId", transactionId) } this @@ -313,7 +325,11 @@ class PeerManager( ) { _incomingPushState.value = IncomingChecking scope.launch(Dispatchers.IO) a@ { - api.request("preparePeerPushCredit", PreparePeerPushCreditResponse.serializer()) { + api.request( + "preparePeerPushCredit", + PreparePeerPushCreditResponse.serializer(), + progressToken = talerUri ?: transactionId ?: "preparePeerPushCredit", + ) { talerUri?.let { put("talerUri", talerUri) } transactionId?.let { put("transactionId", transactionId) } this diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt @@ -306,6 +306,7 @@ class WithdrawManager( api.request( "prepareBankIntegratedWithdrawal", PrepareBankIntegratedWithdrawalResponse.serializer(), + progressToken = uri, ) { put("talerWithdrawUri", uri) }.onError { error -> @@ -417,7 +418,11 @@ class WithdrawManager( status.copy(status = if (loading) Loading else Updating) } - api.request("getWithdrawalDetailsForAmount", WithdrawalDetailsForAmount.serializer()) { + api.request( + "getWithdrawalDetailsForAmount", + WithdrawalDetailsForAmount.serializer(), + progressToken = exchange.exchangeBaseUrl, + ) { put("exchangeBaseUrl", exchange.exchangeBaseUrl) put("amount", amount.toJSONString()) }.onError { error -> @@ -463,7 +468,11 @@ class WithdrawManager( @UiThread fun prepareManualWithdrawal(uri: String) = scope.launch { _withdrawStatus.value = WithdrawStatus(status = Loading) - api.request("prepareWithdrawExchange", WithdrawExchangeResponse.serializer()) { + api.request( + "prepareWithdrawExchange", + WithdrawExchangeResponse.serializer(), + progressToken = uri, + ) { put("talerUri", uri) }.onError { handleError("prepareWithdrawExchange", it) @@ -517,7 +526,11 @@ class WithdrawManager( val talerWithdrawUri = status.talerWithdrawUri ?: error("no talerWithdrawUri") val amountInfo = status.amountInfo - api.request("acceptBankIntegratedWithdrawal", AcceptWithdrawalResponse.serializer()) { + api.request( + "acceptBankIntegratedWithdrawal", + AcceptWithdrawalResponse.serializer(), + progressToken = talerWithdrawUri, + ) { restrictAge?.let { put("restrictAge", it) } amountInfo?.let { put("amount", it.amountRaw.toJSONString()) } put("exchangeBaseUrl", exchangeBaseUrl) diff --git a/wallet/src/main/res/values-de/strings.xml b/wallet/src/main/res/values-de/strings.xml @@ -147,6 +147,9 @@ <string name="send_peer_expiration_hours">Stunden</string> <string name="share_payment">Zahlungs-Link teilen</string> <string name="offline_banner">Kein Internetzugang</string> + <string name="request_progress_retrying">Erneuter Versuch…</string> + <string name="request_progress_slow">Das Netzwerk ist langsam…</string> + <string name="request_progress_waiting">Warte auf Antwort…</string> <string name="transactions_fail_dialog_title">Transaktion verwerfen</string> <string name="transaction_dummy_title">Unbekannte Transaktion</string> <string name="receive_peer_title">Geld anfordern</string> diff --git a/wallet/src/main/res/values-es/strings.xml b/wallet/src/main/res/values-es/strings.xml @@ -54,6 +54,9 @@ GNU Taler es inmune a muchos tipos de fraude, como el robo de datos de tarjetas <string name="millisecond">%1$d ms</string> <string name="offline">Esta operación requiere acceso a internet. Asegurate de que tu conexión funcione e intentá de nuevo.</string> <string name="offline_banner">Sin acceso a internet</string> + <string name="request_progress_retrying">Volviendo a intentarlo…</string> + <string name="request_progress_slow">La red va lenta…</string> + <string name="request_progress_waiting">A la espera de una respuesta…</string> <string name="ok">Aceptar</string> <string name="open">Abrir</string> <string name="paste">Pegar</string> diff --git a/wallet/src/main/res/values-fr/strings.xml b/wallet/src/main/res/values-fr/strings.xml @@ -152,6 +152,9 @@ <string name="error_no_uri">Erreur : Aucune URI Taler n’a été trouvée</string> <string name="send_peer_expiration_days">Jours</string> <string name="offline_banner">Aucune connexion internet</string> + <string name="request_progress_retrying">Nouvelle tentative…</string> + <string name="request_progress_slow">Le réseau est lent…</string> + <string name="request_progress_waiting">En attente d\'une réponse…</string> <string name="transaction_state_pending_bank">Attente de l\'autorisation de la banque</string> <string name="transactions_abort_dialog_message">Êtes-vous sûr de vouloir annuler cette transaction ? Les fonds encore en transit pourraient être perdus.</string> <string name="button_scan_qr_code_label">Scanner le code QR</string> diff --git a/wallet/src/main/res/values-pl/strings.xml b/wallet/src/main/res/values-pl/strings.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="cancel">Anuluj</string> + <string name="request_progress_retrying">Ponowna próba…</string> + <string name="request_progress_slow">Sieć działa wolno…</string> + <string name="request_progress_waiting">Czekam na odpowiedź…</string> +</resources> diff --git a/wallet/src/main/res/values-uk/strings.xml b/wallet/src/main/res/values-uk/strings.xml @@ -40,6 +40,9 @@ <string name="menu">Меню</string> <string name="offline">Операція потребує доступу до Інтернету. Переконайтеся, що ваше інтернет-з\'єднання працює, і спробуйте ще раз.</string> <string name="offline_banner">Немає доступу до Інтернету</string> + <string name="request_progress_retrying">Повторюю спробу…</string> + <string name="request_progress_slow">Мережа повільна…</string> + <string name="request_progress_waiting">Чекаю на відповідь…</string> <string name="ok">ОК</string> <string name="open">Відкрити</string> <string name="paste">Вставити</string> diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -97,6 +97,12 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="warning">Warning</string> <string name="wire_transfer">Wire transfer</string> + <!-- Request progress --> + + <string name="request_progress_retrying">Retrying…</string> + <string name="request_progress_slow">Network is slow…</string> + <string name="request_progress_waiting">Waiting for response…</string> + <!-- Biometric lock --> <string name="biometric_auth_error">Authentication error: %1$s</string> <string name="biometric_auth_failed">Authentication failed</string>