View Issue Details

IDProjectCategoryView StatusLast Update
0012077Talerwallet (Android App)public2026-11-11 01:36
Reporterbot-cg [agent] Assigned ToResolution deadline
ImplementerReviewer 
Release signed byDeployed by 
VerifierCommunicatorCM_7  
Data protection authority notification requiredNo  
CSIRT notification requiredNo  
Estimated effort (hours)Actual effort (hours)0 
Fast-trackNo
Work packagenone
PrioritynormalSeverityminorReproducibilityhave not tried
Status newResolutionopen 
Summary0012077: 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)
DescriptionWhen 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 Reproduce1. 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.
TagsNo tags attached.
Attached Files
before-android-1.png (33,680 bytes)   
before-android-1.png (33,680 bytes)   
before-android-2.png (38,252 bytes)   
before-android-2.png (38,252 bytes)   
before-ios-1.png (222,054 bytes)   
before-ios-1.png (222,054 bytes)   
before-web-1.png (78,830 bytes)   
before-web-1.png (78,830 bytes)   
before-web-2.png (58,696 bytes)   
before-web-2.png (58,696 bytes)   
after-android-1.png (60,591 bytes)   
after-android-1.png (60,591 bytes)   
after-android-2.png (64,114 bytes)   
after-android-2.png (64,114 bytes)   
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 -->

Required notifications

Address or linkRemarkAdded byNotifiedActions

Relationships

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) 

Activities

Issue History

Date ModifiedUsernameFieldChange
2026-11-11 01:36bot-cgNew Issue
2026-11-11 01:36bot-cgRelationship addedrelated to 0012069
2026-11-11 01:36bot-cgFile Added: before-android-1.png
2026-11-11 01:36bot-cgFile Added: before-android-2.png
2026-11-11 01:36bot-cgFile Added: before-ios-1.png
2026-11-11 01:36bot-cgFile Added: before-web-1.png
2026-11-11 01:36bot-cgFile Added: before-web-2.png
2026-11-11 01:36bot-cgFile Added: after-android-1.png
2026-11-11 01:36bot-cgFile Added: after-android-2.png
2026-11-11 01:36bot-cgFile Added: android-F402-stalled-request-banner.patch