taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

commit 811d0db18bc59987368a8a6c0426c2bf01d3b5f7
parent 6f5fb297f18ab98ad6e40ec8ddc5895e98f78cd0
Author: Iván Ávalos <avalos@disroot.org>
Date:   Tue, 18 Aug 2026 16:23:58 +0200

[wallet] fix events lost to late listeners

destroy() cancelled the message dispatch scope for good, and the ToS refreshes
were keyed only on the exchange list, which is emitted independently of the
state they update. Also give the ToS screen an error state, so a failed
download no longer leaves it on the spinner forever.

Diffstat:
Mwallet/src/main/java/net/taler/wallet/backend/BackendManager.kt | 46+++++++++++++++++++++++++++++++++-------------
Mwallet/src/main/java/net/taler/wallet/exchanges/ExchangeListScreen.kt | 9+++++++--
Mwallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt | 38++++++++++++++------------------------
Mwallet/src/main/java/net/taler/wallet/exchanges/ReviewExchangeTosScreen.kt | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mwallet/src/main/java/net/taler/wallet/main/MainActivity.kt | 3++-
Mwallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentScreen.kt | 4+++-
Mwallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawScreen.kt | 5+++--
Mwallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt | 16+++++++++++-----
8 files changed, 134 insertions(+), 59 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 @@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull @@ -50,31 +51,50 @@ class BackendManager( ignoreUnknownKeys = true coerceInputValues = true } + /** Only one wallet-core may run per process. */ @JvmStatic - private val initialized = AtomicBoolean(false) + private val coreRunning = AtomicBoolean(false) } private val walletCore = TalerWalletCore() private val requestManager = RequestManager() private val networkInterface = NetworkInterface() - private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + /** + * Dispatches messages from wallet-core. Must be replaced after [destroy]: launching + * on a cancelled scope drops every message silently, timing out all requests. + */ + @Volatile + private var scope = newScope() + + private var running = false + + private fun newScope() = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + @Synchronized fun run() { - if (!initialized.getAndSet(true)) { - walletCore.setMessageHandler { onMessageReceived(it) } - walletCore.setHttpClient(networkInterface) - if (BuildConfig.DEBUG) walletCore.setStdoutHandler { - Log.d(TAG_CORE, it) - } - walletCore.run() + if (running) return + if (!coreRunning.compareAndSet(false, true)) { + Log.e(TAG, "refusing to run a second wallet-core in this process") + return + } + running = true + if (!scope.isActive) scope = newScope() + walletCore.setMessageHandler { onMessageReceived(it) } + walletCore.setHttpClient(networkInterface) + if (BuildConfig.DEBUG) walletCore.setStdoutHandler { + Log.d(TAG_CORE, it) } + walletCore.run() } + @Synchronized fun destroy() { - if (initialized.getAndSet(false)) { - scope.cancel() - walletCore.destroy() - } + if (!running) return + running = false + scope.cancel() + walletCore.destroy() + coreRunning.set(false) } suspend fun send(operation: String, args: JSONObject? = null): ApiResponse { diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListScreen.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListScreen.kt @@ -61,6 +61,7 @@ import kotlinx.coroutines.launch import net.taler.wallet.NavigateCallback import net.taler.wallet.R import net.taler.wallet.WalletDestination +import net.taler.wallet.backend.WalletResponse import net.taler.wallet.balances.BalanceManager import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.compose.EmptyComposable @@ -340,8 +341,12 @@ fun handleExchangeAction( } ExchangeAction.ForgetTos -> { scope.launch { - exchangeManager.getExchangeTos(exchange.exchangeBaseUrl)?.let { tos -> - exchangeManager.forgetCurrentTos(exchange.exchangeBaseUrl, tos.currentEtag) + val response = exchangeManager.getExchangeTos(exchange.exchangeBaseUrl) + if (response is WalletResponse.Success) { + exchangeManager.forgetCurrentTos( + exchangeBaseUrl = exchange.exchangeBaseUrl, + currentEtag = response.result.currentEtag, + ) } } } diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt @@ -33,6 +33,7 @@ import net.taler.wallet.main.TAG import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi +import net.taler.wallet.backend.WalletResponse import net.taler.wallet.balances.GetCurrencySpecificationResponse import net.taler.wallet.balances.ScopeInfo import org.json.JSONObject @@ -195,17 +196,11 @@ class ExchangeManager( suspend fun getExchangeTos( exchangeBaseUrl: String, language: String? = null, - ): TosResponse? { - var result: TosResponse? = null - api.request("getExchangeTos", TosResponse.serializer()) { - language?.let { put("acceptLanguage", it) } - put("exchangeBaseUrl", exchangeBaseUrl) - }.onError { error -> - Log.d(TAG, "Error getExchangeTos: $error") - }.onSuccess { - result = it - } - return result + ): WalletResponse<TosResponse> = api.request("getExchangeTos", TosResponse.serializer()) { + language?.let { put("acceptLanguage", it) } + put("exchangeBaseUrl", exchangeBaseUrl) + }.onError { error -> + Log.d(TAG, "Error getExchangeTos: $error") } /** @@ -214,19 +209,14 @@ class ExchangeManager( suspend fun acceptCurrentTos( exchangeBaseUrl: String, currentEtag: String, - ): Boolean { - var success = false - api.request<Unit>("setExchangeTosAccepted") { - put("exchangeBaseUrl", exchangeBaseUrl) - put("etag", currentEtag) - }.onError { error -> - Log.d(TAG, "Error setExchangeTosAccepted: $error") - }.onSuccess { - success = true - // update exchange list - list() - } - return success + ): WalletResponse<Unit> = api.request<Unit>("setExchangeTosAccepted") { + put("exchangeBaseUrl", exchangeBaseUrl) + put("etag", currentEtag) + }.onError { error -> + Log.d(TAG, "Error setExchangeTosAccepted: $error") + }.onSuccess { + // update exchange list + list() } /** diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ReviewExchangeTosScreen.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ReviewExchangeTosScreen.kt @@ -31,6 +31,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -63,36 +65,84 @@ fun ReviewExchangeTosScreen( onNavigateBack: () -> Unit, ) { val exchangeManager = model.exchangeManager + val devMode by model.devMode.observeAsState(false) val scope = rememberCoroutineScope() var tos: TosResponse? by remember { mutableStateOf(null) } + var error: TalerErrorInfo? by remember { mutableStateOf(null) } + var retryTrigger by remember { mutableIntStateOf(0) } var selectedLang by remember { mutableStateOf(Locale.getDefault().language) } - LaunchedEffect(selectedLang) { + LaunchedEffect(selectedLang, retryTrigger) { tos = null - tos = exchangeManager.getExchangeTos(exchangeBaseUrl, selectedLang) + error = null + exchangeManager.getExchangeTos(exchangeBaseUrl, selectedLang) + .onSuccess { tos = it } + .onError { error = it } } TalerSurface { - tos?.let { currentTos -> - ReviewExchangeTosComposable( + val currentTos = tos + val currentError = error + when { + currentTos != null -> ReviewExchangeTosComposable( model = model, tos = currentTos, readOnly = readOnly, onSelectLang = { selectedLang = it }, onAcceptTos = { scope.launch { - if (exchangeManager.acceptCurrentTos( - exchangeBaseUrl = exchangeBaseUrl, - currentEtag = currentTos.currentEtag, - ) - ) { + exchangeManager.acceptCurrentTos( + exchangeBaseUrl = exchangeBaseUrl, + currentEtag = currentTos.currentEtag, + ).onSuccess { onNavigateBack() - } + }.onError { error = it } } }, onNavigateBack = onNavigateBack, ) - } ?: LoadingScreen() + currentError != null -> ReviewExchangeTosErrorComposable( + model = model, + error = currentError, + devMode = devMode, + onRetry = { retryTrigger++ }, + onNavigateBack = onNavigateBack, + ) + + else -> LoadingScreen() + } + } +} + +@Composable +fun ReviewExchangeTosErrorComposable( + error: TalerErrorInfo, + devMode: Boolean, + onRetry: () -> Unit, + onNavigateBack: () -> Unit, + model: MainViewModel? = null, +) { + GlobalScaffold( + model = model, + title = { Text(stringResource(R.string.nav_exchange_tos)) }, + onNavigateBack = onNavigateBack, + bottomBar = { + BottomButtonBox { + Button( + modifier = Modifier.systemBarsPaddingBottom(), + onClick = onRetry, + ) { + Text(stringResource(R.string.transactions_retry)) + } + } + }, + ) { innerPadding -> + ErrorComposable( + error = error, + modifier = Modifier.padding(innerPadding), + devMode = devMode, + message = stringResource(R.string.exchange_tos_error, ""), + ) } } diff --git a/wallet/src/main/java/net/taler/wallet/main/MainActivity.kt b/wallet/src/main/java/net/taler/wallet/main/MainActivity.kt @@ -350,7 +350,8 @@ class MainActivity : FragmentActivity() { super.onDestroy() TalerNfcService.clearNdefPayload(this) TalerNfcService.stopService(this) - model.stopWallet() + // wallet-core belongs to the retained MainViewModel, not to this activity + if (!isChangingConfigurations) model.stopWallet() } } diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentScreen.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentScreen.kt @@ -48,7 +48,9 @@ fun IncomingPushPaymentScreen( val state = peerManager.incomingPushState.collectAsStateLifecycleAware() val exchanges by exchangeManager.exchanges.observeAsState() - LaunchedEffect(exchanges) { + // keyed on the state too: a list arriving while we are still IncomingChecking + // would otherwise be consumed for nothing and the ToS transition lost for good + LaunchedEffect(exchanges, state.value) { exchanges?.let { peerManager.refreshPeerPushCreditTos(it) } diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawScreen.kt b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawScreen.kt @@ -117,9 +117,10 @@ fun PromptWithdrawScreen( } } - // Detect ToS acceptance + // Detect ToS acceptance. Keyed on the status too: a list arriving before the + // status reaches TosReviewRequired would otherwise be consumed for nothing. val exchanges by exchangeManager.exchanges.observeAsState() - LaunchedEffect(exchanges) { + LaunchedEffect(exchanges, status.status) { exchanges?.let { withdrawManager.refreshTosStatus(it) } } diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt @@ -327,14 +327,13 @@ class WithdrawManager( return@launch } + val alreadyConfirmed = tx.txState.major != TransactionMajorState.Dialog + val status = _withdrawStatus.updateAndGet { value -> updateSelections( value.copy( - status = if (tx.txState.major == TransactionMajorState.Dialog) { - InfoReceived - } else { - AlreadyConfirmed - }, + status = if (alreadyConfirmed) AlreadyConfirmed else InfoReceived, + transactionId = details.transactionId, uriInfo = details.info, exchangeBaseUrl = details.info.defaultExchangeBaseUrl ?: details.info.possibleExchanges.firstOrNull()?.exchangeBaseUrl @@ -342,6 +341,10 @@ class WithdrawManager( ) } + // fetching amount details would overwrite AlreadyConfirmed in the + // conflated flow, possibly before the screen ever observes it + if (alreadyConfirmed) return@launch + // then extend with amount details (not for cash acceptor) if (!status.isCashAcceptor) { getWithdrawalDetailsForAmount( @@ -475,6 +478,9 @@ class WithdrawManager( @UiThread fun refreshTosStatus(exchanges: List<ExchangeItem>) = scope.launch { _withdrawStatus.update { status -> + // only lift the ToS gate: overwriting Confirming would re-enable the + // confirm button mid-confirmation + if (status.status != TosReviewRequired) return@update status var newStatus = status status.exchangeBaseUrl?.let { exchangeBaseUrl -> exchanges.find { it.exchangeBaseUrl == exchangeBaseUrl }?.let { exchange ->