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 { + 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("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 Retrying… Network is slow… + The service is not responding + The wallet will retry automatically in %1$d s. + Retry now Waiting for response…