taler-android

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

commit 7d7c271fae5c582b20331bd54af36e67bc806e63
parent 4ef136060c74e18458d55e3a59be31000a6345e7
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date:   Wed, 15 Jul 2026 15:29:10 +0200

add taldir lookup integration into contact management

Diffstat:
Mwallet/src/main/java/net/taler/wallet/HandleUriScreen.kt | 3+++
Mwallet/src/main/java/net/taler/wallet/WalletNavHost.kt | 11+----------
Mwallet/src/main/java/net/taler/wallet/WalletNavigation.kt | 4+---
Mwallet/src/main/java/net/taler/wallet/contacts/ContactManager.kt | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/contacts/ContactScreen.kt | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
5 files changed, 192 insertions(+), 28 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt b/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt @@ -134,6 +134,9 @@ fun HandleUriScreen( } onNavigateBack() } + action.startsWith("add-contact/", ignoreCase = true) -> { + onNavigate(WalletDestination.Contacts(addContactUri = u2), true) + } else -> { errorInfo = TalerErrorInfo.makeCustomError("Unsupported URI: $u2") } diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt b/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt @@ -49,7 +49,6 @@ import net.taler.wallet.exchanges.ExchangeListScreen import net.taler.wallet.exchanges.ExchangeShoppingScreen import net.taler.wallet.exchanges.ReviewExchangeTosScreen import net.taler.wallet.main.MainScreen -import net.taler.wallet.contacts.PromptAddContactScreen import net.taler.wallet.main.MainViewModel import net.taler.wallet.payment.PayTemplateScreen import net.taler.wallet.peer.IncomingPullPaymentScreen @@ -380,20 +379,12 @@ fun WalletNavHost( ContactScreen( model = model, sendUri = contacts.sendUri, + addContactUri = contacts.addContactUri, scopeInfo = scopeInfo, onNavigate = onNavigate, onNavigateBack = onNavigateBack, ) } - composable<WalletDestination.PromptAddContact> { backStackEntry -> - val dest: WalletDestination.PromptAddContact = backStackEntry.toRoute() - PromptAddContactScreen( - model = model, - uriString = dest.uriString, - onNavigate = onNavigate, - onNavigateBack = onNavigateBack, - ) - } composable<WalletDestination.Mailbox> { MailboxScreen( model = model, diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt b/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt @@ -88,9 +88,6 @@ sealed interface WalletDestination { data class PromptPayTemplate(val uri: String) : WalletDestination @Serializable - data class PromptAddContact(val uriString: String) : WalletDestination - - @Serializable data class WireTransferDetails( val showQrCodes: Boolean, ) : WalletDestination @@ -111,6 +108,7 @@ sealed interface WalletDestination { data class Contacts( val sendUri: String? = null, val scopeInfo: String? = null, + val addContactUri: String? = null, ) : WalletDestination @Serializable diff --git a/wallet/src/main/java/net/taler/wallet/contacts/ContactManager.kt b/wallet/src/main/java/net/taler/wallet/contacts/ContactManager.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString 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 @@ -44,12 +45,24 @@ data class ContactListResponse( val contacts: List<ContactEntry>, ) +@Serializable +data class LookupAliasResponse( + val targetUri: String?, +) + sealed class ContactResult { data object Loading : ContactResult() data class Error(val error: TalerErrorInfo) : ContactResult() data class Success(val contacts: List<ContactEntry>) : ContactResult() } +sealed class LookupResult { + data object Idle : LookupResult() + data object Loading : LookupResult() + data class Error(val error: TalerErrorInfo) : LookupResult() + data class Success(val targetUri: String) : LookupResult() +} + class ContactManager( private val api: WalletBackendApi, private val scope: CoroutineScope, @@ -57,6 +70,13 @@ class ContactManager( private val _contacts = MutableStateFlow<ContactResult>(ContactResult.Loading) val contacts = _contacts.asStateFlow() + private val _lookupResult = MutableStateFlow<LookupResult>(LookupResult.Idle) + val lookupResult = _lookupResult.asStateFlow() + + fun resetLookup() { + _lookupResult.value = LookupResult.Idle + } + fun loadContacts() { _contacts.value = ContactResult.Loading scope.launch { @@ -98,6 +118,35 @@ class ContactManager( } } + fun taldirLookupAlias( + alias: String, + aliasType: String, + taldirBaseUri: String = "https://taldir.gnunet.org", + ) { + _lookupResult.value = LookupResult.Loading + scope.launch { + api.request<LookupAliasResponse>("lookupAlias", LookupAliasResponse.serializer()) { + put("alias", alias) + put("aliasType", aliasType) + put("taldirBaseUrl", taldirBaseUri) + }.onSuccess { resp -> + Log.d(TAG, "Got Taldir target URI for $alias: ${resp.targetUri}") + val targetUri = resp.targetUri + if (targetUri.isNullOrBlank()) { + _lookupResult.value = LookupResult.Error(TalerErrorInfo( + code = TalerErrorCode.UNKNOWN, + message = "Alias not found", + )) + } else { + _lookupResult.value = LookupResult.Success(targetUri) + } + }.onError { + Log.e(TAG, "Error looking up contact: $it") + _lookupResult.value = LookupResult.Error(it) + } + } + } + fun deleteContact(contact: ContactEntry) { scope.launch { api.request<Unit>("deleteContact") { diff --git a/wallet/src/main/java/net/taler/wallet/contacts/ContactScreen.kt b/wallet/src/main/java/net/taler/wallet/contacts/ContactScreen.kt @@ -24,6 +24,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add @@ -31,6 +33,7 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider @@ -53,6 +56,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import net.taler.wallet.NavigateCallback import net.taler.wallet.R import net.taler.wallet.WalletDestination @@ -65,18 +69,62 @@ import net.taler.wallet.transactions.TransactionStateFilter.Nonfinal fun ContactScreen( model: MainViewModel, sendUri: String? = null, + addContactUri: String? = null, scopeInfo: ScopeInfo? = null, onNavigate: NavigateCallback, onNavigateBack: () -> Unit, ) { val contactManager = model.contactManager val contactResult by contactManager.contacts.collectAsState() + val lookupResult by contactManager.lookupResult.collectAsState() var showAddDialog by remember { mutableStateOf(false) } var searchQuery by remember { mutableStateOf("") } var contactToConfirm by remember { mutableStateOf<ContactEntry?>(null) } + var initialAlias by remember { mutableStateOf("") } + var initialAliasType by remember { mutableStateOf("email") } + var initialMailboxBaseUri by remember { mutableStateOf("") } + var initialMailboxAddress by remember { mutableStateOf("") } + var initialSource by remember { mutableStateOf("") } + var initialPetname by remember { mutableStateOf("") } + + fun fillFromUri(uriString: String) { + val uri = uriString.toUri() + val segments = uri.pathSegments + var mailboxAddressTmp = uriString + val aliasType = segments.getOrNull(0) ?: "" + mailboxAddressTmp = mailboxAddressTmp.removePrefix("taler://add-contact/") + mailboxAddressTmp = mailboxAddressTmp.removePrefix("""$aliasType/""") + val alias = segments.getOrNull(1) ?: "" + mailboxAddressTmp = mailboxAddressTmp.removePrefix("""$alias/""") + val mailboxAddressFull = mailboxAddressTmp.split("?")[0] + val mailboxAddress = mailboxAddressFull.split("/").last() + val mailboxBaseUri = buildString { + append("https://") + append(mailboxAddressFull.removeSuffix(mailboxAddress)) + } + val petname = uri.getQueryParameter("petname") ?: "" + + initialAlias = alias + initialAliasType = aliasType + initialMailboxBaseUri = mailboxBaseUri + initialMailboxAddress = mailboxAddress + initialSource = uriString + initialPetname = petname + showAddDialog = true + } + LaunchedEffect(Unit) { contactManager.loadContacts() + addContactUri?.let { fillFromUri(it) } + } + + LaunchedEffect(lookupResult) { + if (lookupResult is LookupResult.Success) { + val uri = (lookupResult as LookupResult.Success).targetUri + contactManager.resetLookup() + fillFromUri(uri) + } } Scaffold( @@ -163,38 +211,113 @@ fun ContactScreen( if (showAddDialog) { AddContactDialog( - onDismiss = { showAddDialog = false }, + initialAlias = initialAlias, + initialAliasType = initialAliasType, + initialMailboxBaseUri = initialMailboxBaseUri, + initialMailboxAddress = initialMailboxAddress, + initialSource = initialSource, + initialPetname = initialPetname, + onDismiss = { + showAddDialog = false + contactManager.resetLookup() + initialAlias = "" + initialAliasType = "email" + initialMailboxBaseUri = "" + initialMailboxAddress = "" + initialSource = "" + initialPetname = "" + }, onAdd = { alias, aliasType, mailboxBaseUri, mailboxAddress, source, petname -> contactManager.addContact(alias, aliasType, mailboxBaseUri, mailboxAddress, source, petname) showAddDialog = false - } + }, + onLookup = { alias, aliasType -> + contactManager.taldirLookupAlias(alias, aliasType) + }, + lookupResult = lookupResult, ) } } @Composable fun AddContactDialog( + initialAlias: String = "", + initialAliasType: String = "email", + initialMailboxBaseUri: String = "", + initialMailboxAddress: String = "", + initialSource: String = "", + initialPetname: String = "", onDismiss: () -> Unit, onAdd: (String, String, String, String, String, String) -> Unit, + onLookup: (String, String) -> Unit, + lookupResult: LookupResult, ) { - var alias by remember { mutableStateOf("") } - var aliasType by remember { mutableStateOf("") } - var mailboxBaseUri by remember { mutableStateOf("") } - var mailboxAddress by remember { mutableStateOf("") } - var source by remember { mutableStateOf("") } - var petname by remember { mutableStateOf("") } + var alias by remember { mutableStateOf(initialAlias) } + var aliasType by remember { mutableStateOf(initialAliasType) } + var mailboxBaseUri by remember { mutableStateOf(initialMailboxBaseUri) } + var mailboxAddress by remember { mutableStateOf(initialMailboxAddress) } + var source by remember { mutableStateOf(initialSource) } + var petname by remember { mutableStateOf(initialPetname) } + + LaunchedEffect(initialAlias) { alias = initialAlias } + LaunchedEffect(initialAliasType) { aliasType = initialAliasType } + LaunchedEffect(initialMailboxBaseUri) { mailboxBaseUri = initialMailboxBaseUri } + LaunchedEffect(initialMailboxAddress) { mailboxAddress = initialMailboxAddress } + LaunchedEffect(initialSource) { source = initialSource } + LaunchedEffect(initialPetname) { petname = initialPetname } AlertDialog( onDismissRequest = onDismiss, title = { Text("Add Contact") }, text = { - Column { - OutlinedTextField(value = alias, onValueChange = { alias = it }, label = { Text("Alias") }) - OutlinedTextField(value = aliasType, onValueChange = { aliasType = it }, label = { Text("Alias Type") }) - OutlinedTextField(value = mailboxBaseUri, onValueChange = { mailboxBaseUri = it }, label = { Text("Mailbox Base URI") }) - OutlinedTextField(value = mailboxAddress, onValueChange = { mailboxAddress = it }, label = { Text("Mailbox Address") }) - OutlinedTextField(value = source, onValueChange = { source = it }, label = { Text("Source") }) - OutlinedTextField(value = petname, onValueChange = { petname = it }, label = { Text("Petname") }) + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + OutlinedTextField( + value = petname, + onValueChange = { petname = it }, + label = { Text("Petname") }, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) + ) + OutlinedTextField( + value = alias, + onValueChange = { alias = it }, + label = { Text("Alias") }, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) + ) + OutlinedTextField( + value = aliasType, + onValueChange = { aliasType = it }, + label = { Text("Alias Type") }, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) + ) + + if (lookupResult is LookupResult.Loading) { + CircularProgressIndicator( + modifier = Modifier + .padding(vertical = 8.dp) + .align(Alignment.CenterHorizontally) + ) + } else { + Button( + onClick = { onLookup(alias, aliasType) }, + enabled = alias.isNotBlank() && aliasType.isNotBlank(), + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) + ) { + Text("Lookup with Taldir") + } + } + + if (lookupResult is LookupResult.Error) { + Text( + text = "Lookup failed: ${lookupResult.error.userFacingMsg}", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + + OutlinedTextField(value = mailboxBaseUri, onValueChange = { mailboxBaseUri = it }, label = { Text("Mailbox Base URI") }, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) + OutlinedTextField(value = mailboxAddress, onValueChange = { mailboxAddress = it }, label = { Text("Mailbox Address") }, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) + OutlinedTextField(value = source, onValueChange = { source = it }, label = { Text("Source") }, modifier = Modifier.fillMaxWidth()) } }, confirmButton = {