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