taler-android

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

commit 7ab101b0a5db61403d95d2c0271cd634e4722f44
parent 03ae2c2c29d88b93217bce6d1baec37c807866d1
Author: Iván Ávalos <avalos@disroot.org>
Date:   Wed, 22 Jul 2026 22:14:51 +0200

[wallet] navigation fixes + simplified URI handling

Diffstat:
Mwallet/src/main/java/net/taler/wallet/HandleUriScreen.kt | 230++++++++++++++++++++++++++++++++++++-------------------------------------------
Mwallet/src/main/java/net/taler/wallet/WalletNavHost.kt | 31+++++++++++++++++--------------
2 files changed, 121 insertions(+), 140 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt b/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt @@ -1,19 +1,3 @@ -/* - * This file is part of GNU Taler - * (C) 2026 Taler Systems S.A. - * - * GNU Taler is free software; you can redistribute it and/or modify it under the - * terms of the GNU General Public License as published by the Free Software - * Foundation; either version 3, or (at your option) any later version. - * - * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - * A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - package net.taler.wallet import android.net.Uri @@ -30,10 +14,10 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.core.net.toUri -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.RetryScreen @@ -56,104 +40,102 @@ fun HandleUriScreen( var processing by remember { mutableStateOf(false) } var errorInfo by remember { mutableStateOf<TalerErrorInfo?>(null) } val networkStatus by model.networkManager.networkStatus.observeAsState() - val devMode by model.devMode.observeAsState(false) val scope = rememberCoroutineScope() fun processTalerUri() { if (processing) return processing = true + scope.launch { + try { + val u = getTalerAction(model, uriString.trim().toUri(), 3) + Log.v(TAG, "found action $u") - val uri = uriString.trim().toUri() - // wifi connection logic omitted for now as it uses requireContext() - - getTalerAction(model, uri, 3, MutableLiveData()).observeForever { u -> - Log.v(TAG, "found action $u") - - if (u.startsWith("payto://", ignoreCase = true)) { - onNavigate(WalletDestination.PaytoUri(u), true) - return@observeForever - } - - val normalizedURL = u.lowercase(Locale.ROOT) - var ext = false - val action = normalizedURL.substring( - if (normalizedURL.startsWith("taler://", ignoreCase = true)) { - "taler://".length - } else if (normalizedURL.startsWith("ext+taler://", ignoreCase = true)) { - ext = true - "ext+taler://".length - } else if (normalizedURL.startsWith("taler+http://", ignoreCase = true) && - model.devMode.value == true - ) { - "taler+http://".length - } else { - normalizedURL.length + if (u.startsWith("payto://", ignoreCase = true)) { + onNavigate(WalletDestination.PaytoUri(u), true) + return@launch } - ) - val u2 = if (ext) { - "taler://" + u.substring("ext+taler://".length) - } else u + val normalizedURL = u.lowercase(Locale.ROOT) + var ext = false + val action = normalizedURL.substring( + if (normalizedURL.startsWith("taler://", ignoreCase = true)) { + "taler://".length + } else if (normalizedURL.startsWith("ext+taler://", ignoreCase = true)) { + ext = true + "ext+taler://".length + } else if (normalizedURL.startsWith("taler+http://", ignoreCase = true) && + model.devMode.value == true + ) { + "taler+http://".length + } else { + normalizedURL.length + } + ) + + val u2 = if (ext) { + "taler://" + u.substring("ext+taler://".length) + } else u - when { - action.startsWith("pay/", ignoreCase = true) -> { - scope.launch { + when { + action.startsWith("pay/", ignoreCase = true) -> { model.paymentManager.preparePay(u2)?.let { transactionId -> if (model.transactionManager.selectTransaction(transactionId)) { onNavigate(WalletDestination.TransactionPayment, true) } } } - } - action.startsWith("withdraw/", ignoreCase = true) -> { - model.withdrawManager.resetWithdrawal() - onNavigate(WalletDestination.PromptWithdraw( - withdrawUri = u2, - editableCurrency = false - ), true) - } - action.startsWith("withdraw-exchange/", ignoreCase = true) -> { - model.withdrawManager.resetWithdrawal() - onNavigate(WalletDestination.PromptWithdraw( - withdrawExchangeUri = u2, - editableCurrency = false - ), true) - } - action.startsWith("refund/", ignoreCase = true) -> { - model.showProgressBar.value = true - model.refundManager.refund(u2).observeForever { status -> - model.showProgressBar.value = false - when (status) { - is RefundStatus.Error -> { - errorInfo = status.error - } - is RefundStatus.Success -> { - onNavigateBack() + action.startsWith("withdraw/", ignoreCase = true) -> { + model.withdrawManager.resetWithdrawal() + onNavigate(WalletDestination.PromptWithdraw( + withdrawUri = u2, + editableCurrency = false + ), true) + } + action.startsWith("withdraw-exchange/", ignoreCase = true) -> { + model.withdrawManager.resetWithdrawal() + onNavigate(WalletDestination.PromptWithdraw( + withdrawExchangeUri = u2, + editableCurrency = false + ), true) + } + action.startsWith("refund/", ignoreCase = true) -> { + model.showProgressBar.value = true + model.refundManager.refund(u2).observeForever { status -> + model.showProgressBar.value = false + when (status) { + is RefundStatus.Error -> { + errorInfo = status.error + } + is RefundStatus.Success -> { + onNavigateBack() + } } } } - } - action.startsWith("pay-pull/", ignoreCase = true) -> { - model.peerManager.preparePeerPullDebit(u2) - onNavigate(WalletDestination.PromptPullPayment, true) - } - action.startsWith("pay-push/", ignoreCase = true) -> { - model.peerManager.preparePeerPushCredit(u2) - onNavigate(WalletDestination.PromptPushPayment, true) - } - action.startsWith("pay-template/", ignoreCase = true) -> { - onNavigate(WalletDestination.PromptPayTemplate(u2), true) - } - action.startsWith("dev-experiment/", ignoreCase = true) -> { - model.applyDevExperiment(u2) { error -> - errorInfo = error + action.startsWith("pay-pull/", ignoreCase = true) -> { + model.peerManager.preparePeerPullDebit(u2) + onNavigate(WalletDestination.PromptPullPayment, true) + } + action.startsWith("pay-push/", ignoreCase = true) -> { + model.peerManager.preparePeerPushCredit(u2) + onNavigate(WalletDestination.PromptPushPayment, true) + } + action.startsWith("pay-template/", ignoreCase = true) -> { + onNavigate(WalletDestination.PromptPayTemplate(u2), true) + } + action.startsWith("dev-experiment/", ignoreCase = true) -> { + model.applyDevExperiment(u2) { error -> + errorInfo = error + } + onNavigateBack() + } + else -> { + errorInfo = TalerErrorInfo.makeCustomError("Unsupported URI: $u2") } - onNavigateBack() - } - else -> { - errorInfo = TalerErrorInfo.makeCustomError("Unsupported URI: $u2") } + } catch (_: CancellationException) { } + processing = false } } @@ -182,49 +164,45 @@ fun HandleUriScreen( } } -private fun getTalerAction( +private suspend fun getTalerAction( model: MainViewModel, uri: Uri, maxRedirects: Int, - actionFound: MutableLiveData<String>, -): MutableLiveData<String> { - val scheme = uri.scheme ?: return actionFound - - if (scheme == "http" || scheme == "https") { - model.viewModelScope.launch(Dispatchers.IO) { - try { - val conn = URL(uri.toString()).openConnection() as HttpURLConnection - conn.setRequestProperty("Accept", "text/html") - conn.connectTimeout = 5000 - conn.requestMethod = "HEAD" - conn.connect() - val status = conn.responseCode - - if (status == HttpURLConnection.HTTP_OK || status == HttpURLConnection.HTTP_PAYMENT_REQUIRED) { +): String { + val scheme = uri.scheme ?: return uri.toString() + if (scheme != "http" && scheme != "https") return uri.toString() + + return withContext(Dispatchers.IO) { + try { + val conn = URL(uri.toString()).openConnection() as HttpURLConnection + conn.setRequestProperty("Accept", "text/html") + conn.connectTimeout = 5000 + conn.requestMethod = "HEAD" + conn.connect() + when (conn.responseCode) { + HttpURLConnection.HTTP_OK, + HttpURLConnection.HTTP_PAYMENT_REQUIRED -> { val talerHeader = conn.headerFields["Taler"] if (talerHeader != null && talerHeader[0] != null) { - val talerHeaderUri = talerHeader[0].toUri() - getTalerAction(model, talerHeaderUri, 0, actionFound) + getTalerAction(model, talerHeader[0].toUri(), 0) } else { - // Error handling omitted for brevity + uri.toString() } - } else if (status == HttpURLConnection.HTTP_MOVED_TEMP - || status == HttpURLConnection.HTTP_MOVED_PERM - || status == HttpURLConnection.HTTP_SEE_OTHER - ) { + } + HttpURLConnection.HTTP_MOVED_TEMP, + HttpURLConnection.HTTP_MOVED_PERM, + HttpURLConnection.HTTP_SEE_OTHER -> { val location = conn.headerFields["Location"] if (location != null && location[0] != null) { - val locUri = location[0].toUri() - getTalerAction(model, locUri, maxRedirects - 1, actionFound) + getTalerAction(model, location[0].toUri(), maxRedirects - 1) + } else { + uri.toString() } } - } catch (e: IOException) { - // Error handling omitted + else -> uri.toString() } + } catch (_: IOException) { + uri.toString() } - } else { - actionFound.postValue(uri.toString()) } - - return actionFound } diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt b/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt @@ -25,6 +25,9 @@ import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -83,22 +86,12 @@ fun WalletNavHost( } val onNavigate: NavigateCallback = { dest, popupToStart -> - navController.navigate(dest) { - if (popupToStart) { - // If startDestination is HandleUri, it means we are in "intent mode" - // where we want to close the app on back. - // We pop everything up to the root (the NavHost's startDestination) inclusively. - if (startDestination is WalletDestination.HandleUri) { - popUpTo(navController.graph.id) { - inclusive = true - } - } else { - popUpTo(startDestination) { - inclusive = false - } - } + if (popupToStart) { + navController.navigate(WalletDestination.Main) { + popUpTo(navController.graph.id) { inclusive = true } } } + navController.navigate(dest) } val onNavigateBack: () -> Unit = { @@ -116,6 +109,16 @@ fun WalletNavHost( } } + val previousLaunchUri = remember { mutableStateOf(launchUri) } + + LaunchedEffect(launchUri) { + val prev = previousLaunchUri.value + previousLaunchUri.value = launchUri + if (launchUri != null && prev != launchUri) { + navController.navigate(WalletDestination.HandleUri(launchUri)) + } + } + NavHost( navController = navController, startDestination = startDestination,