View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0012077 | Taler | wallet (Android App) | public | 2026-11-11 01:36 | 2026-11-11 01:36 |
| Reporter | bot-cg [agent] | Assigned To | Resolution deadline | ||
| Implementer | Reviewer | ||||
| Release signed by | Deployed by | ||||
| Verifier | Communicator | CM_7 | |||
| Data protection authority notification required | No | ||||
| CSIRT notification required | No | ||||
| Estimated effort (hours) | Actual effort (hours) | 0 | |||
| Fast-track | No | ||||
| Work package | none | ||||
| Priority | normal | Severity | minor | Reproducibility | have not tried |
| Status | new | Resolution | open | ||
| Summary | 0012077: Android: a stalled request shows only "Network is slow... / Retrying..." -- no reason, no time to the next retry, no Cancel or "Retry now" (patch attached) | ||||
| Description | When a wallet-core request carrying a progress token keeps failing (the merchant, bank or exchange host cannot be reached, the service answers 5xx, ...), wallet-core reports request-progress-phase "delayed"/"stalled" and request-progress-error notifications with the error, the delay until the next attempt and a retry counter, and offers retryProgressTokenNow / cancelProgressToken. Android shows only a yellow strip "Network is slow..." / "Retrying..." over an otherwise empty screen: no reason, no time to the next attempt, no Cancel, no "Retry now". The only way out is the back arrow. What the user sees: - Android, taler://pay/merchant.invalid/2026.315-0TESTORDER/ (merchant host does not resolve): title "Purchase", strip "Network is slow... / Retrying...", nothing else, for ever (before-android-1.png). Same for a bank-integrated withdrawal whose bank does not resolve, taler://withdraw/bank.invalid/taler-integration/00000000-... : "Withdraw from bank" + the strip (before-android-2.png). - iOS, same pay URI: "Couldn't establish a connection", the error ("HTTP request failed: A server with the specified hostname could not be found."), "Copy Details", "Next retry in: N seconds", "Retry now" and "Cancel" (before-ios-1.png). - WebExtension, taler://withdraw-exchange/exchange.invalid/: panel "The service is not responding", "Latest connection error: The wallet could not reach the service. Check your connection and try again." with technical details, "The wallet will retry automatically in N seconds.", "Cancel" and "Retry now" (before-web-1.png). For the unreachable bank it fails at once with "The wallet could not reach the service" and a Retry button (before-web-2.png). Code (origin/master 4c483527): - wallet/src/main/java/net/taler/wallet/backend/Notifications.kt:94-102: RequestProgressError decodes error, nextRetryDelay, retryCounter. - wallet/src/main/java/net/taler/wallet/main/RequestProgress.kt:46-52: withError() keeps only retrying = true; error and delay are dropped. - wallet/src/main/java/net/taler/wallet/compose/NetworkStatusBanner.kt:46-66, 92-115: the strip (request_progress_slow / _retrying / _waiting), no actions. - wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt:97: cancelProgressToken exists (used for DB migration), there is no retryProgressTokenNow. - Reference: taler-typescript-core packages/wallet-webui/src/ui/RequestProgress.tsx:22-89; taler-ios TalerWallet1/Views/HelperViews/LoadingView.swift:11-141 (StalledView). The patch (android-F402-stalled-request-banner.patch, 6 files): - RequestProgress keeps error, nextRetryDelay and the time they arrived. - WalletBackendApi.retryProgressTokenNow; MainViewModel.cancelRequest() / retryRequestNow(). - NetworkStatusBanner: in the stalled phase the title is "The service is not responding", followed by the error text, a countdown "The wallet will retry automatically in N s." and the buttons Cancel (existing string; cancels the progress token and leaves the screen) and "Retry now". The delayed phase looks as before. - GlobalScaffold wires the two actions; strings.xml: 3 new strings. The error text is wallet-core's hint; the translated per-code messages proposed in 0012069 would apply here too. Verified: :wallet:assembleFdroidSim and :wallet:testFdroidSimUnitTest pass; after-android-1.png and after-android-2.png are the two scenarios with the patched build; "Retry now" sends retryProgressTokenNow (logcat), "Cancel" cancels and returns to the balances. Assisted by LLM | ||||
| Steps To Reproduce | 1. Open taler://pay/merchant.invalid/2026.315-0TESTORDER/ (or taler://withdraw/bank.invalid/taler-integration/00000000-0000-0000-0000-000000000000). 2. Wait 15 s: Android shows only "Network is slow... / Retrying..." with no reason and no action. | ||||
| Tags | No tags attached. | ||||
| Attached Files | android-F402-stalled-request-banner.patch (9,858 bytes)
diff --git a/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt b/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt
index 1ae9eab1..abe4ae38 100644
--- a/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt
+++ b/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt
@@ -101,6 +101,13 @@ class WalletBackendApi(
}
}
+ suspend fun retryProgressTokenNow(operation: String, progressToken: String): WalletResponse<Unit> {
+ return request("retryProgressTokenNow") {
+ put("operation", operation)
+ put("progressToken", progressToken)
+ }
+ }
+
suspend fun sendRequest(
operation: String,
args: JSONObject? = null,
diff --git a/wallet/src/main/java/net/taler/wallet/compose/GlobalScaffold.kt b/wallet/src/main/java/net/taler/wallet/compose/GlobalScaffold.kt
index fb3c0905..31be2716 100644
--- a/wallet/src/main/java/net/taler/wallet/compose/GlobalScaffold.kt
+++ b/wallet/src/main/java/net/taler/wallet/compose/GlobalScaffold.kt
@@ -134,6 +134,13 @@ fun GlobalScaffold(
NetworkStatusBanner(
offline = offline,
progress = if (offline) null else requestProgress,
+ onCancel = model?.let {
+ {
+ it.cancelRequest()
+ onNavigateBack?.invoke()
+ }
+ },
+ onRetry = model?.let { { it.retryRequestNow() } },
)
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/compose/NetworkStatusBanner.kt b/wallet/src/main/java/net/taler/wallet/compose/NetworkStatusBanner.kt
index a7f72f29..52e42f84 100644
--- a/wallet/src/main/java/net/taler/wallet/compose/NetworkStatusBanner.kt
+++ b/wallet/src/main/java/net/taler/wallet/compose/NetworkStatusBanner.kt
@@ -29,14 +29,19 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
import net.taler.wallet.R
import net.taler.wallet.backend.ProgressPhase
import net.taler.wallet.main.RequestProgress
@@ -46,6 +51,8 @@ import net.taler.wallet.ui.theme.TalerTheme
fun NetworkStatusBanner(
offline: Boolean,
progress: RequestProgress?,
+ onCancel: (() -> Unit)? = null,
+ onRetry: (() -> Unit)? = null,
) {
when {
offline -> StatusBanner(
@@ -59,8 +66,31 @@ fun NetworkStatusBanner(
containerColor = TalerTheme.extraColors.warningContainer,
contentColor = TalerTheme.extraColors.onWarningContainer,
) {
- Text(stringResource(R.string.request_progress_slow))
+ val stalled = progress.phase == ProgressPhase.Stalled
+ Text(stringResource(
+ if (stalled) R.string.request_progress_stalled else R.string.request_progress_slow
+ ))
+ val error = progress.error
+ if (stalled && error != null) {
+ // what went wrong, so a dead service can be told from a slow one
+ Text(
+ text = error.userFacingMsg.ifEmpty {
+ stringResource(R.string.error_code_only, error.rawCode)
+ },
+ modifier = Modifier.padding(top = 4.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ )
+ }
RetryStatus(progress)
+ if (stalled && (onCancel != null || onRetry != null)) Row {
+ if (onCancel != null) TextButton(onClick = onCancel) {
+ Text(stringResource(R.string.cancel))
+ }
+ if (onRetry != null) TextButton(onClick = onRetry) {
+ Text(stringResource(R.string.request_progress_retry_now))
+ }
+ }
}
}
}
@@ -90,6 +120,27 @@ private fun StatusBanner(
@Composable
private fun RetryStatus(progress: RequestProgress) {
+ val delayMs = progress.nextRetryDelay?.ms?.takeIf { it > 0 }
+ if (delayMs != null) {
+ // count down to wallet-core's next attempt
+ val secondsLeft by produceState(
+ initialValue = remaining(progress.errorTimeMs, delayMs),
+ progress.errorTimeMs, delayMs,
+ ) {
+ while (value > 0) {
+ delay(1000)
+ value = remaining(progress.errorTimeMs, delayMs)
+ }
+ }
+ if (secondsLeft > 0) {
+ Text(
+ text = stringResource(R.string.request_progress_next_retry, secondsLeft),
+ modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
+ style = MaterialTheme.typography.labelMediumEmphasized,
+ )
+ return
+ }
+ }
val text = when {
progress.retrying -> R.string.request_progress_retrying
progress.phase == ProgressPhase.Stalled -> R.string.request_progress_waiting
@@ -112,6 +163,9 @@ private fun RetryStatus(progress: RequestProgress) {
}
}
+private fun remaining(sinceMs: Long, delayMs: Long): Long =
+ ((sinceMs + delayMs - System.currentTimeMillis() + 999) / 1000).coerceAtLeast(0)
+
@Preview
@Composable
private fun NetworkStatusBannerOfflinePreview() {
diff --git a/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt
index 79e38f06..7ca4cc97 100644
--- a/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt
+++ b/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt
@@ -409,6 +409,28 @@ class MainViewModel(
mShowObservabilityLog.value = false
}
+ /** Stop waiting for the request shown in the network status banner. */
+ fun cancelRequest() {
+ val progress = mRequestProgress.value ?: return
+ mRequestProgress.value = null
+ viewModelScope.launch {
+ api.cancelProgressToken(progress.operation, progress.progressToken)
+ }
+ }
+
+ /** Ask wallet-core to retry the stalled request now instead of after its back-off. */
+ fun retryRequestNow() {
+ val progress = mRequestProgress.value ?: return
+ mRequestProgress.update {
+ if (it?.progressToken == progress.progressToken) {
+ it.copy(error = null, nextRetryDelay = null)
+ } else it
+ }
+ viewModelScope.launch {
+ api.retryProgressTokenNow(progress.operation, progress.progressToken)
+ }
+ }
+
fun hintNetworkAvailability(isAvailable: Boolean) {
viewModelScope.launch {
api.request<Unit>("hintNetworkAvailability") {
diff --git a/wallet/src/main/java/net/taler/wallet/main/RequestProgress.kt b/wallet/src/main/java/net/taler/wallet/main/RequestProgress.kt
index d57cd085..cbac931b 100644
--- a/wallet/src/main/java/net/taler/wallet/main/RequestProgress.kt
+++ b/wallet/src/main/java/net/taler/wallet/main/RequestProgress.kt
@@ -16,14 +16,22 @@
package net.taler.wallet.main
+import net.taler.common.RelativeTime
import net.taler.wallet.backend.NotificationPayload
import net.taler.wallet.backend.ProgressPhase
+import net.taler.wallet.backend.TalerErrorInfo
data class RequestProgress(
val operation: String,
val progressToken: String,
val phase: ProgressPhase,
val retrying: Boolean = false,
+ /** last error wallet-core reported while retrying this request */
+ val error: TalerErrorInfo? = null,
+ /** delay until wallet-core's next attempt, as reported with [error] */
+ val nextRetryDelay: RelativeTime? = null,
+ /** when the error was received, to count down [nextRetryDelay] */
+ val errorTimeMs: Long = 0,
)
internal fun RequestProgress?.withPhase(
@@ -45,8 +53,14 @@ internal fun RequestProgress?.withPhase(
internal fun RequestProgress?.withError(
payload: NotificationPayload.RequestProgressError,
+ nowMs: Long = System.currentTimeMillis(),
): RequestProgress? = if (this?.progressToken == payload.progressToken) {
- copy(retrying = true)
+ copy(
+ retrying = true,
+ error = payload.error,
+ nextRetryDelay = payload.nextRetryDelay,
+ errorTimeMs = nowMs,
+ )
} else {
this
}
diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml
index 89750709..cea7843e 100644
--- a/wallet/src/main/res/values/strings.xml
+++ b/wallet/src/main/res/values/strings.xml
@@ -105,6 +105,9 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish
<string name="request_progress_retrying">Retrying…</string>
<string name="request_progress_slow">Network is slow…</string>
+ <string name="request_progress_stalled">The service is not responding</string>
+ <string name="request_progress_next_retry">The wallet will retry automatically in %1$d s.</string>
+ <string name="request_progress_retry_now">Retry now</string>
<string name="request_progress_waiting">Waiting for response…</string>
<!-- Biometric lock -->
| ||||
| related to | 0012069 | new | Android: errors are shown as "Error" + wallet-core's English hint -- no translated message even for "could not reach the service"; "Unsupported URI" is a hard-coded English literal (patch attached) |
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-11-11 01:36 | bot-cg | New Issue | |
| 2026-11-11 01:36 | bot-cg | Relationship added | related to 0012069 |
| 2026-11-11 01:36 | bot-cg | File Added: before-android-1.png | |
| 2026-11-11 01:36 | bot-cg | File Added: before-android-2.png | |
| 2026-11-11 01:36 | bot-cg | File Added: before-ios-1.png | |
| 2026-11-11 01:36 | bot-cg | File Added: before-web-1.png | |
| 2026-11-11 01:36 | bot-cg | File Added: before-web-2.png | |
| 2026-11-11 01:36 | bot-cg | File Added: after-android-1.png | |
| 2026-11-11 01:36 | bot-cg | File Added: after-android-2.png | |
| 2026-11-11 01:36 | bot-cg | File Added: android-F402-stalled-request-banner.patch |