taler-android

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

commit 56ea5ae7223aa4fb6bfc412cb31d87773af46a02
parent 32fcb65e12cdec6913dd993d954a77429575051f
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date:   Thu, 29 Oct 2026 07:04:16 +0100

[wallet] actually display contact expiration durations

Diffstat:
Mwallet/src/main/java/net/taler/wallet/contacts/AliasRegistrationScreen.kt | 11+++++++++++
Mwallet/src/main/java/net/taler/wallet/contacts/ContactManager.kt | 83++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mwallet/src/main/java/net/taler/wallet/contacts/ContactScreen.kt | 44++++++++++++++++++++++++++++++++++++++------
Mwallet/src/main/java/net/taler/wallet/mailbox/MailboxManager.kt | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/mailbox/MailboxScreen.kt | 43+++++++++++++++++++++++++++++++++----------
5 files changed, 203 insertions(+), 29 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/contacts/AliasRegistrationScreen.kt b/wallet/src/main/java/net/taler/wallet/contacts/AliasRegistrationScreen.kt @@ -75,6 +75,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import net.taler.lib.android.toAbsoluteTime import net.taler.wallet.R import net.taler.wallet.compose.TalerSurface import net.taler.wallet.mailbox.MailboxConfiguration @@ -469,6 +470,16 @@ fun UserAliasCard( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + contact.expiration?.ms?.let { ms -> + if (ms > 0) { + val context = LocalContext.current + Text( + text = "Valid until: ${ms.toAbsoluteTime(context)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } Row(verticalAlignment = Alignment.CenterVertically) { IconButton(onClick = onEditNickname) { diff --git a/wallet/src/main/java/net/taler/wallet/contacts/ContactManager.kt b/wallet/src/main/java/net/taler/wallet/contacts/ContactManager.kt @@ -25,6 +25,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import net.taler.common.RelativeTime +import net.taler.common.Timestamp import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo @@ -32,6 +33,14 @@ import net.taler.wallet.backend.WalletBackendApi import net.taler.wallet.main.TAG import org.json.JSONObject +fun RelativeTime.toExpirationTimestamp(): Timestamp { + return if (this.ms > 0) { + Timestamp.fromMillis(System.currentTimeMillis() + this.ms) + } else { + Timestamp.fromMillis(0) + } +} + @Serializable data class ContactEntry( val alias: String, @@ -42,6 +51,7 @@ data class ContactEntry( val source: String, val petname: String, val isSelf: Boolean = false, + val expiration: Timestamp = Timestamp.fromMillis(0), ) { val mailboxBaseUri: String get() = mailboxBaseUrl val self: Boolean get() = isSelf @@ -54,7 +64,14 @@ data class ContactListResponse( @Serializable data class LookupAliasResponse( - val targetUri: String?, + val targetUri: String? = null, + val validFor: RelativeTime? = null, +) + +@Serializable +data class CompleteRegisterAliasResponse( + @SerialName("valid_for") + val validFor: RelativeTime? = null, ) @Serializable @@ -69,7 +86,7 @@ data class TaldirRegisterRequest( @Serializable data class TaldirRegisterResponse( - val validFor: RelativeTime? = null, + val challenge: String? = null, ) sealed class ContactResult { @@ -82,14 +99,18 @@ sealed class LookupResult { data object Idle : LookupResult() data object Loading : LookupResult() data class Error(val error: TalerErrorInfo) : LookupResult() - data class Success(val targetUri: String, val source: String) : LookupResult() + data class Success( + val targetUri: String, + val source: String, + val validFor: RelativeTime? = null, + ) : LookupResult() } sealed class RegisterResult { data object Idle : RegisterResult() data object Loading : RegisterResult() data class Error(val error: TalerErrorInfo) : RegisterResult() - data class Success(val validFor: RelativeTime?, val targetUri: String) : RegisterResult() + data class Success(val targetUri: String) : RegisterResult() data object CompletionLoading : RegisterResult() data object CompletionSuccess : RegisterResult() } @@ -175,6 +196,7 @@ class ContactManager( source: String, petname: String, isSelf: Boolean = false, + expiration: Timestamp = Timestamp.fromMillis(0), ) { val entry = ContactEntry( alias = alias, @@ -184,6 +206,7 @@ class ContactManager( source = source, petname = petname, isSelf = isSelf, + expiration = expiration, ) scope.launch { api.request<Unit>("addContact") { @@ -222,6 +245,20 @@ class ContactManager( map[key] = AliasRegistrationStatus.NotRegistered } else if (targetUri.trimEnd('/') == expectedPath || targetUri.endsWith(currentMailboxAddress)) { map[key] = AliasRegistrationStatus.Registered(targetUri) + val exp = resp.validFor?.toExpirationTimestamp() ?: Timestamp.fromMillis(0) + val existingPetname = (_contacts.value as? ContactResult.Success) + ?.contacts?.find { it.alias == alias && it.aliasType == aliasType } + ?.petname?.ifBlank { null } ?: alias + addContact( + alias = alias, + aliasType = aliasType, + mailboxBaseUri = currentMailboxBaseUrl, + mailboxAddress = currentMailboxAddress, + source = "self", + petname = existingPetname, + isSelf = true, + expiration = exp, + ) } else { map[key] = AliasRegistrationStatus.Mismatched(targetUri) } @@ -246,7 +283,7 @@ class ContactManager( put("aliasType", aliasType) put("taldirBaseUrl", taldirBaseUri) }.onSuccess { resp -> - Log.d(TAG, "Got Taldir target URI for $alias: ${resp.targetUri}") + Log.d(TAG, "Got Taldir target URI for $alias: ${resp.targetUri}, validFor: ${resp.validFor}") val targetUri = resp.targetUri if (targetUri.isNullOrBlank()) { _lookupResult.value = LookupResult.Error(TalerErrorInfo( @@ -254,7 +291,11 @@ class ContactManager( message = "Alias not found", )) } else { - _lookupResult.value = LookupResult.Success(targetUri, taldirBaseUri) + _lookupResult.value = LookupResult.Success( + targetUri = targetUri, + source = taldirBaseUri, + validFor = resp.validFor, + ) } }.onError { Log.e(TAG, "Error looking up contact: $it") @@ -296,7 +337,7 @@ class ContactManager( }.onSuccess { Log.d(TAG, "Taldir registration initiated for $alias: $it") val targetUri = if (mailboxBaseUrl.isBlank() || mailboxAddress.isBlank()) "" else "$mailboxBaseUrl/$mailboxAddress" - _registerResult.value = RegisterResult.Success(it.validFor, targetUri) + _registerResult.value = RegisterResult.Success(targetUri) }.onError { Log.e(TAG, "Error initiating Taldir registration: $it") _registerResult.value = RegisterResult.Error(it) @@ -314,19 +355,35 @@ class ContactManager( ) { _registerResult.value = RegisterResult.CompletionLoading scope.launch { - api.request<Unit>("completeRegisterAlias") { + api.request("completeRegisterAlias", CompleteRegisterAliasResponse.serializer()) { put("alias", alias) put("aliasType", aliasType) put("taldirBaseUrl", taldirBaseUri) put("mailboxBaseUrl", mailboxBaseUrl) put("mailboxAddress", mailboxAddress) put("challenge", solution) - }.onSuccess { - Log.d(TAG, "Taldir registration completed for $alias") + }.onSuccess { resp -> + Log.d(TAG, "Taldir registration completed for $alias, validFor=${resp.validFor}") + val exp = resp.validFor?.toExpirationTimestamp() ?: Timestamp.fromMillis(0) + val existingPetname = (_contacts.value as? ContactResult.Success) + ?.contacts?.find { it.alias == alias && it.aliasType == aliasType } + ?.petname?.ifBlank { null } ?: alias + if (mailboxBaseUrl.isNotBlank() && mailboxAddress.isNotBlank()) { + addContact( + alias = alias, + aliasType = aliasType, + mailboxBaseUri = mailboxBaseUrl, + mailboxAddress = mailboxAddress, + source = "self", + petname = existingPetname, + isSelf = true, + expiration = exp, + ) + } _registerResult.value = RegisterResult.CompletionSuccess - }.onError { - Log.e(TAG, "Error completing Taldir registration: $it") - _registerResult.value = RegisterResult.Error(it) + }.onError { error -> + Log.e(TAG, "Error completing Taldir registration: $error") + _registerResult.value = RegisterResult.Error(error) } } } diff --git a/wallet/src/main/java/net/taler/wallet/contacts/ContactScreen.kt b/wallet/src/main/java/net/taler/wallet/contacts/ContactScreen.kt @@ -71,11 +71,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri +import net.taler.common.Timestamp +import net.taler.lib.android.toAbsoluteTime import net.taler.wallet.NavigateCallback import net.taler.wallet.R import net.taler.wallet.WalletDestination @@ -115,12 +118,14 @@ fun ContactScreen( var initialMailboxAddress by remember { mutableStateOf("") } var initialSource by remember { mutableStateOf("") } var initialPetname by remember { mutableStateOf("") } + var initialExpiration by remember { mutableStateOf(Timestamp.fromMillis(0)) } - fun fillFromUri(uriString: String, overriddenSource: String? = null) { + fun fillFromUri(uriString: String, overriddenSource: String? = null, expiration: Timestamp = Timestamp.fromMillis(0)) { + if (!uriString.startsWith("taler://add-contact/", ignoreCase = true)) return val uri = uriString.toUri() val segments = uri.pathSegments var mailboxAddressTmp = uriString - val aliasType = segments.getOrNull(0) ?: "" + val aliasType = segments.getOrNull(0) ?: "email" mailboxAddressTmp = mailboxAddressTmp.removePrefix("taler://add-contact/") mailboxAddressTmp = mailboxAddressTmp.removePrefix("""$aliasType/""") val alias = segments.getOrNull(1) ?: "" @@ -137,14 +142,20 @@ fun ContactScreen( } val petname = uri.getQueryParameter("petname") ?: "" val source = overriddenSource ?: uri.getQueryParameter("source") ?: "QR" + val sourceBaseUrl = uri.getQueryParameter("sourceBaseUrl") initialAlias = alias - initialAliasType = aliasType + initialAliasType = aliasType.ifBlank { "email" } initialMailboxBaseUri = mailboxBaseUri initialMailboxAddress = mailboxAddress initialSource = source initialPetname = petname + initialExpiration = expiration showAddDialog = true + + if (!sourceBaseUrl.isNullOrBlank() && expiration.ms == 0L) { + contactManager.taldirLookupAlias(alias, aliasType, sourceBaseUrl) + } } LaunchedEffect(Unit) { @@ -155,8 +166,7 @@ fun ContactScreen( LaunchedEffect(lookupResult) { if (lookupResult is LookupResult.Success) { val res = lookupResult as LookupResult.Success - contactManager.resetLookup() - fillFromUri(res.targetUri, res.source) + initialExpiration = res.validFor?.toExpirationTimestamp() ?: Timestamp.fromMillis(0) } } @@ -262,6 +272,7 @@ fun ContactScreen( source = contact.source, petname = newNickname, isSelf = contact.isSelf, + expiration = contact.expiration, ) contactToEdit = null } @@ -316,10 +327,11 @@ fun ContactScreen( initialMailboxAddress = "" initialSource = "" initialPetname = "" + initialExpiration = Timestamp.fromMillis(0) }, onProceed = { alias, aliasType, mailboxBaseUri, mailboxAddress, source -> showAddDialog = false - pendingContact = PendingContact(alias, aliasType, mailboxBaseUri, mailboxAddress, source) + pendingContact = PendingContact(alias, aliasType, mailboxBaseUri, mailboxAddress, source, initialExpiration) showNicknameDialog = true }, onLookup = { alias, aliasType -> @@ -358,6 +370,7 @@ fun ContactScreen( mailboxAddress = pending.mailboxAddress, source = pending.source, petname = nickname, + expiration = pending.expiration, ) showNicknameDialog = false pendingContact = null @@ -368,6 +381,7 @@ fun ContactScreen( initialMailboxAddress = "" initialSource = "" initialPetname = "" + initialExpiration = Timestamp.fromMillis(0) }, enabled = nickname.isNotBlank() ) { @@ -392,6 +406,7 @@ data class PendingContact( val mailboxBaseUri: String, val mailboxAddress: String, val source: String, + val expiration: Timestamp = Timestamp.fromMillis(0), ) @Composable @@ -506,6 +521,15 @@ fun AddContactDialog( } LaunchedEffect(initialSource) { source = initialSource } + LaunchedEffect(lookupResult) { + if (lookupResult is LookupResult.Success) { + mailbox = lookupResult.targetUri + if (lookupResult.source.isNotBlank()) { + source = lookupResult.source + } + } + } + AlertDialog( onDismissRequest = onDismiss, title = { Text("Add Contact") }, @@ -656,6 +680,7 @@ fun ContactGroupCard( onShowQr: (ContactEntry) -> Unit, onSelect: ((ContactEntry) -> Unit)? = null, ) { + val context = LocalContext.current val isSelfGroup = contactsInGroup.any { it.isSelf || it.source == "self" } val distinctPetnames = contactsInGroup @@ -758,6 +783,13 @@ fun ContactGroupCard( ) } } + if (contact.expiration.ms > 0) { + Text( + text = "Valid until: ${contact.expiration.ms.toAbsoluteTime(context)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } if (onSelect != null) { IconButton(onClick = { onSelect(contact) }) { diff --git a/wallet/src/main/java/net/taler/wallet/mailbox/MailboxManager.kt b/wallet/src/main/java/net/taler/wallet/mailbox/MailboxManager.kt @@ -22,15 +22,19 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString +import net.taler.common.Amount import net.taler.common.Timestamp import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi import net.taler.wallet.main.TAG +import net.taler.wallet.peer.PreparePeerPullDebitResponse +import net.taler.wallet.peer.PreparePeerPushCreditResponse import org.json.JSONObject @Serializable @@ -70,6 +74,12 @@ data class MailboxResponse( val mailboxConfiguration: MailboxConfiguration? = null, ) +@Serializable +data class MailboxMessageDetails( + val summary: String? = null, + val amount: Amount? = null, +) + sealed class MailboxResult { data object Loading : MailboxResult() data class Error(val error: TalerErrorInfo) : MailboxResult() @@ -88,6 +98,9 @@ class MailboxManager( private val _mailbox = MutableStateFlow<MailboxResult>(MailboxResult.Loading) val mailbox = _mailbox.asStateFlow() + private val _messageDetails = MutableStateFlow<Map<String, MailboxMessageDetails>>(emptyMap()) + val messageDetails = _messageDetails.asStateFlow() + private var started = false fun start() { @@ -108,6 +121,41 @@ class MailboxManager( } } + fun fetchDetailsForMessage(message: MailboxMessageRecord) { + val uri = message.talerUri + if (_messageDetails.value.containsKey(uri)) return + + scope.launch { + try { + if (uri.startsWith("taler://pay-push")) { + api.request("preparePeerPushCredit", PreparePeerPushCreditResponse.serializer()) { + put("talerUri", uri) + }.onSuccess { res -> + _messageDetails.update { + it + (uri to MailboxMessageDetails( + summary = res.contractTerms.summary.ifBlank { null }, + amount = res.amountRaw + )) + } + } + } else if (uri.startsWith("taler://pay-pull")) { + api.request("preparePeerPullDebit", PreparePeerPullDebitResponse.serializer()) { + put("talerUri", uri) + }.onSuccess { res -> + _messageDetails.update { + it + (uri to MailboxMessageDetails( + summary = res.contractTerms.summary.ifBlank { null }, + amount = res.amountRaw + )) + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error fetching details for message URI: $uri", e) + } + } + } + fun loadMailbox(baseUrl: String = "https://mailbox.gnunet.org") { _mailbox.value = MailboxResult.Loading scope.launch { @@ -151,6 +199,9 @@ class MailboxManager( api.request("getMailboxMessages", MailboxMessagesResponse.serializer()) .onSuccess { response -> _mailbox.value = MailboxResult.Success(config, response.messages) + response.messages.forEach { msg -> + fetchDetailsForMessage(msg) + } }.onError { Log.e(TAG, "Error getting mailbox messages: $it") _mailbox.value = MailboxResult.Error(it) diff --git a/wallet/src/main/java/net/taler/wallet/mailbox/MailboxScreen.kt b/wallet/src/main/java/net/taler/wallet/mailbox/MailboxScreen.kt @@ -25,13 +25,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.ArrowDownward -import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -65,6 +63,7 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch @@ -132,9 +131,6 @@ fun MailboxScreen( Icon(Icons.Default.Info, contentDescription = "Show Mailbox Address") } } - IconButton(onClick = { showUrlDialog = true }) { - Icon(Icons.Default.Settings, contentDescription = "Configure Mailbox") - } if (state is MailboxResult.Success) { IconButton(onClick = { mailboxManager.refreshMailbox(state.config) }) { Icon(Icons.Default.Refresh, contentDescription = "Refresh") @@ -154,7 +150,7 @@ fun MailboxScreen( is MailboxResult.Loading -> Text("Loading...", modifier = Modifier.padding(16.dp)) is MailboxResult.Error -> Text("Error: ${res.error}", modifier = Modifier.padding(16.dp)) is MailboxResult.Success -> { - MessageList(res.messages, onNavigate) { message -> + MessageList(res.messages, mailboxManager, onNavigate) { message -> messageToDelete = message } } @@ -252,6 +248,7 @@ fun MailboxScreen( @Composable fun MessageList( messages: List<MailboxMessageRecord>, + mailboxManager: MailboxManager? = null, onNavigate: NavigateCallback, onDelete: (MailboxMessageRecord) -> Unit, ) { @@ -260,7 +257,7 @@ fun MessageList( } else { LazyColumn { items(messages) { message -> - MessageItem(message, onNavigate, onDelete) + MessageItem(message, mailboxManager, onNavigate, onDelete) HorizontalDivider() } } @@ -270,6 +267,7 @@ fun MessageList( @Composable fun MessageItem( message: MailboxMessageRecord, + mailboxManager: MailboxManager? = null, onNavigate: NavigateCallback, onDelete: (MailboxMessageRecord) -> Unit, ) { @@ -279,6 +277,13 @@ fun MessageItem( message.talerUri.startsWith("taler://pay/") || message.talerUri.startsWith("taler://pay-template") + val messageDetailsState = mailboxManager?.messageDetails?.collectAsState() + val details = messageDetailsState?.value?.get(message.talerUri) + + LaunchedEffect(message.talerUri) { + mailboxManager?.fetchDetailsForMessage(message) + } + Row( modifier = Modifier .fillMaxWidth() @@ -286,6 +291,24 @@ fun MessageItem( verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { + if (details?.summary != null) { + Text( + text = details.summary, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 2.dp) + ) + } + if (details?.amount != null) { + Text( + text = details.amount.toString(), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = if (isPayPush) Color(0xFF2E7D32) else Color(0xFFE65100), + modifier = Modifier.padding(bottom = 6.dp) + ) + } + if (isPayPush) { Button( onClick = { onNavigate(WalletDestination.HandleUri(message.talerUri), false) }, @@ -296,7 +319,7 @@ fun MessageItem( modifier = Modifier.padding(bottom = 6.dp) ) { Icon( - imageVector = Icons.Default.ArrowDownward, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, modifier = Modifier.padding(end = 6.dp) ) @@ -312,7 +335,7 @@ fun MessageItem( modifier = Modifier.padding(bottom = 6.dp) ) { Icon( - imageVector = Icons.Default.ArrowUpward, + imageVector = Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, modifier = Modifier.padding(end = 6.dp) )