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:
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 ->