taler-android

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

commit ed7a763f5ead1fbff6a7e46d2f4f4f76ea4bd899
parent 1b50d69d370b2995d282c1ef51fdab27a94f09a0
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date:   Wed, 21 Oct 2026 15:55:02 +0200

aliases: Remodel screen to better reflect new wallet Contacts API and allow delayed registration and unlinking with proper alias management

Diffstat:
Mwallet/src/main/java/net/taler/wallet/contacts/AliasRegistrationScreen.kt | 732++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mwallet/src/main/java/net/taler/wallet/contacts/ContactManager.kt | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mwallet/src/main/java/net/taler/wallet/contacts/ContactScreen.kt | 6+++---
Mwallet/src/main/java/net/taler/wallet/mailbox/MailboxManager.kt | 2+-
Mwallet/src/main/res/values/strings.xml | 2+-
5 files changed, 610 insertions(+), 226 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 @@ -16,39 +16,58 @@ package net.taler.wallet.contacts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.taler.wallet.R @@ -57,7 +76,6 @@ import net.taler.wallet.mailbox.MailboxConfiguration import net.taler.wallet.mailbox.MailboxResult import net.taler.wallet.main.MainViewModel -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AliasRegistrationScreen( model: MainViewModel, @@ -65,6 +83,32 @@ fun AliasRegistrationScreen( ) { val mailboxState by model.mailboxManager.mailbox.collectAsState() val registerResult by model.contactManager.registerResult.collectAsState() + val contactsResult by model.contactManager.contacts.collectAsState() + val aliasStatuses by model.contactManager.aliasStatuses.collectAsState() + + var showAddAliasDialog by remember { mutableStateOf(false) } + var activeRegistrationAlias by remember { mutableStateOf<ContactEntry?>(null) } + var isUnlinking by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + model.contactManager.loadContacts() + } + + val mailboxConfig = (mailboxState as? MailboxResult.Success)?.config + + LaunchedEffect(contactsResult, mailboxConfig) { + if (contactsResult is ContactResult.Success && mailboxConfig != null) { + val userAliases = (contactsResult as ContactResult.Success).contacts.filter { it.self } + userAliases.forEach { contact -> + model.contactManager.checkAliasRegistration( + alias = contact.alias, + aliasType = contact.aliasType, + currentMailboxBaseUrl = mailboxConfig.mailboxBaseUrl, + currentMailboxAddress = mailboxConfig.hAddress, + ) + } + } + } DisposableEffect(Unit) { onDispose { @@ -72,48 +116,159 @@ fun AliasRegistrationScreen( } } + val userAliases = (contactsResult as? ContactResult.Success)?.contacts?.filter { it.self } ?: emptyList() + AliasRegistrationContent( - mailboxState = mailboxState, + userAliases = userAliases, + mailboxConfig = mailboxConfig, + aliasStatuses = aliasStatuses, registerResult = registerResult, + activeRegistrationAlias = activeRegistrationAlias, + isUnlinking = isUnlinking, onNavigateBack = onNavigateBack, - onRegister = { alias, aliasType, taldirBaseUri, targetUri, durationMonths -> - model.contactManager.taldirRegister(alias, aliasType, taldirBaseUri, targetUri, durationMonths) + onAddAliasClick = { showAddAliasDialog = true }, + onStartRegister = { contact -> + activeRegistrationAlias = contact + isUnlinking = false + if (mailboxConfig != null) { + model.contactManager.taldirRegister( + alias = contact.alias, + aliasType = contact.aliasType, + taldirBaseUri = "https://taldir.gnunet.org", + mailboxBaseUrl = mailboxConfig.mailboxBaseUrl, + mailboxAddress = mailboxConfig.hAddress, + durationMonths = 12, + ) + } }, - onComplete = { alias, aliasType, taldirBaseUri, targetUri, solution -> - model.contactManager.completeTaldirRegister(alias, aliasType, taldirBaseUri, targetUri, solution) + onCompleteRegister = { solution -> + val contact = activeRegistrationAlias + if (contact != null) { + if (isUnlinking) { + model.contactManager.completeTaldirRegister( + alias = contact.alias, + aliasType = contact.aliasType, + taldirBaseUri = "https://taldir.gnunet.org", + mailboxBaseUrl = "", + mailboxAddress = "", + solution = solution, + ) + } else if (mailboxConfig != null) { + model.contactManager.completeTaldirRegister( + alias = contact.alias, + aliasType = contact.aliasType, + taldirBaseUri = "https://taldir.gnunet.org", + mailboxBaseUrl = mailboxConfig.mailboxBaseUrl, + mailboxAddress = mailboxConfig.hAddress, + solution = solution, + ) + } + } }, - onRecover = { alias, aliasType, taldirBaseUri, targetUri -> - model.contactManager.recoverRegistration(alias, aliasType, taldirBaseUri, targetUri) + onRecoverRegister = { + val contact = activeRegistrationAlias + if (contact != null) { + if (isUnlinking) { + model.contactManager.recoverRegistration( + alias = contact.alias, + aliasType = contact.aliasType, + taldirBaseUri = "https://taldir.gnunet.org", + expectedTargetUri = "", + ) + } else if (mailboxConfig != null) { + val expectedTargetUri = "${mailboxConfig.mailboxBaseUrl.trimEnd('/')}/${mailboxConfig.hAddress}" + model.contactManager.recoverRegistration( + alias = contact.alias, + aliasType = contact.aliasType, + taldirBaseUri = "https://taldir.gnunet.org", + expectedTargetUri = expectedTargetUri, + ) + } + } + }, + onCancelRegister = { + activeRegistrationAlias = null + isUnlinking = false + model.contactManager.resetRegistration() + }, + onDeleteAlias = { contact -> + val key = "${contact.aliasType}:${contact.alias}" + val isRegistered = aliasStatuses[key] is AliasRegistrationStatus.Registered + if (isRegistered) { + activeRegistrationAlias = contact + isUnlinking = true + model.contactManager.taldirDeleteAlias( + alias = contact.alias, + aliasType = contact.aliasType, + taldirBaseUri = "https://taldir.gnunet.org", + durationMonths = 12, + ) + } else { + model.contactManager.deleteContact(contact) + } }, - onDelete = { alias, aliasType, taldirBaseUri, durationMonths -> - model.contactManager.taldirDeleteAlias(alias, aliasType, taldirBaseUri, durationMonths) + onRegistrationCompleted = { + activeRegistrationAlias?.let { contact -> + if (isUnlinking) { + model.contactManager.deleteContact(contact) + } else if (mailboxConfig != null) { + model.contactManager.checkAliasRegistration( + alias = contact.alias, + aliasType = contact.aliasType, + currentMailboxBaseUrl = mailboxConfig.mailboxBaseUrl, + currentMailboxAddress = mailboxConfig.hAddress, + ) + } + } + activeRegistrationAlias = null + isUnlinking = false + model.contactManager.resetRegistration() } ) + + if (showAddAliasDialog && mailboxConfig != null) { + AddAliasDialog( + onDismiss = { showAddAliasDialog = false }, + onConfirm = { alias, aliasType, nickname -> + model.contactManager.addContact( + alias = alias, + aliasType = aliasType, + mailboxBaseUri = mailboxConfig.mailboxBaseUrl, + mailboxAddress = mailboxConfig.hAddress, + source = "self", + petname = nickname, + self = true, + ) + model.contactManager.checkAliasRegistration( + alias = alias, + aliasType = aliasType, + currentMailboxBaseUrl = mailboxConfig.mailboxBaseUrl, + currentMailboxAddress = mailboxConfig.hAddress, + ) + showAddAliasDialog = false + } + ) + } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun AliasRegistrationContent( - mailboxState: MailboxResult, + userAliases: List<ContactEntry>, + mailboxConfig: MailboxConfiguration?, + aliasStatuses: Map<String, AliasRegistrationStatus>, registerResult: RegisterResult, + activeRegistrationAlias: ContactEntry?, + isUnlinking: Boolean, onNavigateBack: () -> Unit, - onRegister: (String, String, String, String, Int) -> Unit, - onComplete: (String, String, String, String, String) -> Unit, - onRecover: (String, String, String, String) -> Unit, - onDelete: (String, String, String, Int) -> Unit, + onAddAliasClick: () -> Unit, + onStartRegister: (ContactEntry) -> Unit, + onCompleteRegister: (solution: String) -> Unit, + onRecoverRegister: () -> Unit, + onCancelRegister: () -> Unit, + onDeleteAlias: (ContactEntry) -> Unit, + onRegistrationCompleted: () -> Unit, ) { - var aliasType by remember { mutableStateOf("email") } - var aliasTypeExpanded by remember { mutableStateOf(false) } - var alias by remember { mutableStateOf("") } - var durationMonths by remember { mutableStateOf("12") } - var solution by remember { mutableStateOf("") } - val taldirBaseUri = "https://taldir.gnunet.org" - - val aliasTypeOptions = listOf( - stringResource(R.string.alias_registration_alias_email), - stringResource(R.string.alias_registration_alias_phone), - ) - Scaffold( topBar = { TopAppBar( @@ -125,28 +280,226 @@ fun AliasRegistrationContent( contentDescription = null, ) } + }, + actions = { + IconButton(onClick = onAddAliasClick, enabled = mailboxConfig != null) { + Icon(Icons.Default.Add, contentDescription = "Add Alias") + } } ) + }, + floatingActionButton = { + if (mailboxConfig != null) { + FloatingActionButton(onClick = onAddAliasClick) { + Icon(Icons.Default.Add, contentDescription = "Add Alias") + } + } } ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .padding(16.dp) - .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) ) { - val mailboxAddress = (mailboxState as? MailboxResult.Success)?.let { - "${it.config.mailboxBaseUrl}/${it.config.hAddress}" + if (mailboxConfig == null) { + Text( + text = "Mailbox not configured or loading...", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(vertical = 16.dp) + ) + } else if (userAliases.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No aliases added yet. Tap + to add an alias.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { Spacer(modifier = Modifier.height(8.dp)) } + items(userAliases) { contact -> + val key = "${contact.aliasType}:${contact.alias}" + val status = aliasStatuses[key] ?: AliasRegistrationStatus.Checking + UserAliasCard( + contact = contact, + status = status, + isRegistering = activeRegistrationAlias?.alias == contact.alias && activeRegistrationAlias?.aliasType == contact.aliasType, + onRegister = { onStartRegister(contact) }, + onDelete = { onDeleteAlias(contact) } + ) + } + item { Spacer(modifier = Modifier.height(80.dp)) } + } + } + } + } + + if (activeRegistrationAlias != null && registerResult !is RegisterResult.Idle) { + RegistrationChallengeDialog( + contact = activeRegistrationAlias, + isUnlinking = isUnlinking, + registerResult = registerResult, + onComplete = onCompleteRegister, + onRecover = onRecoverRegister, + onCancel = onCancelRegister, + onDismissSuccess = onRegistrationCompleted, + ) + } +} + +@Composable +fun UserAliasCard( + contact: ContactEntry, + status: AliasRegistrationStatus, + isRegistering: Boolean, + onRegister: () -> Unit, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = contact.alias, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + if (contact.petname.isNotBlank()) { + Text( + text = contact.petname, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = "Type: ${contact.aliasType}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, contentDescription = "Delete Alias") + } } - if (registerResult is RegisterResult.Idle || registerResult is RegisterResult.Loading || registerResult is RegisterResult.Error) { + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + when (status) { + is AliasRegistrationStatus.Checking -> { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Text( + text = " Checking status...", + style = MaterialTheme.typography.bodySmall, + ) + } + } + is AliasRegistrationStatus.Registered -> { + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFF2E7D32).copy(alpha = 0.15f)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = Color(0xFF2E7D32), + modifier = Modifier.size(16.dp) + ) + Text( + text = " Registered", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF2E7D32), + fontWeight = FontWeight.Medium + ) + } + } + } + is AliasRegistrationStatus.NotRegistered, is AliasRegistrationStatus.Mismatched -> { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp) + ) + Text( + text = if (status is AliasRegistrationStatus.Mismatched) " Mismatched Target" else " Not Registered", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + fontWeight = FontWeight.Medium + ) + } + } + } + + Button( + onClick = onRegister, + enabled = !isRegistering, + modifier = Modifier.padding(start = 8.dp) + ) { + Text(stringResource(R.string.alias_registration_button)) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddAliasDialog( + onDismiss: () -> Unit, + onConfirm: (alias: String, aliasType: String, nickname: String) -> Unit, +) { + var aliasType by remember { mutableStateOf("email") } + var aliasTypeExpanded by remember { mutableStateOf(false) } + var alias by remember { mutableStateOf("") } + var nickname by remember { mutableStateOf("") } + + val aliasTypeOptions = listOf("email", "phone") + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Alias") }, + text = { + Column { ExposedDropdownMenuBox( expanded = aliasTypeExpanded, onExpandedChange = { aliasTypeExpanded = !aliasTypeExpanded }, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) ) { OutlinedTextField( value = aliasType, @@ -155,23 +508,19 @@ fun AliasRegistrationContent( label = { Text(stringResource(R.string.alias_registration_alias_type)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = aliasTypeExpanded) }, colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), - modifier = Modifier - .menuAnchor() - .fillMaxWidth(), - enabled = registerResult !is RegisterResult.Loading + modifier = Modifier.menuAnchor().fillMaxWidth(), ) ExposedDropdownMenu( expanded = aliasTypeExpanded, onDismissRequest = { aliasTypeExpanded = false } ) { - aliasTypeOptions.forEach { selectionOption -> + aliasTypeOptions.forEach { option -> DropdownMenuItem( - text = { Text(selectionOption) }, + text = { Text(option) }, onClick = { - aliasType = selectionOption + aliasType = option aliasTypeExpanded = false - }, - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + } ) } } @@ -182,156 +531,130 @@ fun AliasRegistrationContent( onValueChange = { alias = it }, label = { Text(stringResource(R.string.alias_registration_alias)) }, placeholder = { - Text( - if (aliasType == stringResource(R.string.alias_registration_alias_email)) { - stringResource(R.string.alias_registration_alias_hint_email) - } else if (aliasType == stringResource(R.string.alias_registration_alias_phone)) { - stringResource(R.string.alias_registration_alias_hint_phone) - } else "" - ) + Text(if (aliasType == "email") "user@example.com" else "+1234567890") }, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - enabled = registerResult !is RegisterResult.Loading - ) - OutlinedTextField( - value = durationMonths, - onValueChange = { durationMonths = it }, - label = { Text(stringResource(R.string.alias_registration_duration)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - enabled = registerResult !is RegisterResult.Loading + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) ) - Button( - onClick = { - mailboxAddress?.let { targetUri -> - onRegister( - alias, - aliasType, - taldirBaseUri, - targetUri, - durationMonths.toIntOrNull() ?: 12 - ) - } - }, - enabled = aliasType.isNotBlank() && alias.isNotBlank() && mailboxAddress != null && registerResult !is RegisterResult.Loading, + OutlinedTextField( + value = nickname, + onValueChange = { nickname = it }, + label = { Text("Nickname / Petname") }, modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.alias_registration_button)) - } + ) + } + }, + confirmButton = { + Button( + onClick = { onConfirm(alias.trim(), aliasType, nickname.trim()) }, + enabled = alias.isNotBlank() + ) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} - Spacer(modifier = Modifier.height(8.dp)) +@Composable +fun RegistrationChallengeDialog( + contact: ContactEntry, + isUnlinking: Boolean = false, + registerResult: RegisterResult, + onComplete: (solution: String) -> Unit, + onRecover: () -> Unit, + onCancel: () -> Unit, + onDismissSuccess: () -> Unit, +) { + var solution by remember { mutableStateOf("") } + val titleText = if (isUnlinking) "Unlink ${contact.alias}" else "Register ${contact.alias}" + val actionProgressText = if (isUnlinking) " Unlinking alias..." else " Registering alias..." + val promptText = if (isUnlinking) { + "Enter the challenge solution code sent to unlink ${contact.alias}:" + } else { + "Enter the challenge solution code sent for ${contact.alias}:" + } + val successText = if (isUnlinking) "Unlinking successful!" else stringResource(R.string.alias_registration_success) - Button( - onClick = { - onDelete( - alias, - aliasType, - taldirBaseUri, - durationMonths.toIntOrNull() ?: 12 + AlertDialog( + onDismissRequest = onCancel, + title = { Text(titleText) }, + text = { + Column { + when (val res = registerResult) { + is RegisterResult.Loading, is RegisterResult.CompletionLoading -> { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + Text(actionProgressText, modifier = Modifier.padding(start = 8.dp)) + } + } + is RegisterResult.Error -> { + Text( + text = stringResource(R.string.alias_registration_error, res.error.userFacingMsg), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(bottom = 8.dp) ) - }, - enabled = aliasType.isNotBlank() && alias.isNotBlank() && registerResult !is RegisterResult.Loading, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.alias_registration_delete)) - } - } - - when (val res = registerResult) { - is RegisterResult.Loading -> { - Text(stringResource(R.string.loading), modifier = Modifier.padding(top = 8.dp)) - } - is RegisterResult.Error -> { - Text( - text = stringResource(R.string.alias_registration_error, res.error.userFacingMsg), - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 8.dp) - ) - } - is RegisterResult.Success -> { - if (res.validFor != null) { + TextButton( + onClick = onRecover, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.alias_registration_out_of_band)) + } + } + is RegisterResult.Success -> { Text( - text = stringResource(R.string.alias_registration_exists), - modifier = Modifier.padding(top = 8.dp) + text = promptText, + modifier = Modifier.padding(bottom = 8.dp) ) - } else { OutlinedTextField( value = solution, onValueChange = { solution = it }, label = { Text(stringResource(R.string.alias_registration_solution)) }, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) ) - Button( - onClick = { - mailboxAddress?.let { targetUri -> - onComplete( - alias, - aliasType, - taldirBaseUri, - targetUri, - solution - ) - } - }, - enabled = solution.isNotBlank() && mailboxAddress != null, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.ok)) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Button( - onClick = { - onRecover( - alias, - aliasType, - taldirBaseUri, - res.targetUri - ) - }, - enabled = alias.isNotBlank(), + TextButton( + onClick = onRecover, modifier = Modifier.fillMaxWidth() ) { Text(stringResource(R.string.alias_registration_out_of_band)) } } + is RegisterResult.CompletionSuccess -> { + Text( + text = successText, + color = MaterialTheme.colorScheme.primary, + ) + } + else -> {} } - is RegisterResult.CompletionLoading -> { - Text(stringResource(R.string.loading), modifier = Modifier.padding(top = 8.dp)) + } + }, + confirmButton = { + if (registerResult is RegisterResult.Success) { + Button( + onClick = { onComplete(solution.trim()) }, + enabled = solution.isNotBlank() + ) { + Text(stringResource(R.string.ok)) } - is RegisterResult.CompletionSuccess -> { - Text( - text = stringResource(R.string.alias_registration_success), - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 8.dp) - ) - Button( - onClick = onNavigateBack, - modifier = Modifier.fillMaxWidth().padding(top = 16.dp) - ) { - Text(stringResource(R.string.ok)) - } + } else if (registerResult is RegisterResult.CompletionSuccess) { + Button(onClick = onDismissSuccess) { + Text(stringResource(R.string.ok)) } - else -> {} } - - if (mailboxAddress == null) { - Text( - text = if (mailboxState is MailboxResult.Loading) "Loading mailbox..." else "Mailbox not configured", - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 8.dp) - ) + }, + dismissButton = { + if (registerResult !is RegisterResult.CompletionSuccess) { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.cancel)) + } } } - } + ) } @Preview @@ -339,47 +662,36 @@ fun AliasRegistrationContent( fun AliasRegistrationContentPreview() { TalerSurface { AliasRegistrationContent( - mailboxState = MailboxResult.Success( - MailboxConfiguration( + userAliases = listOf( + ContactEntry( + alias = "alice@example.com", + aliasType = "email", mailboxBaseUrl = "https://mailbox.taler.net", - privateKey = "", - privateEncryptionKey = "", - hAddress = "alice", - expiration = net.taler.common.Timestamp.fromMillis(0) - ), - emptyList() + mailboxAddress = "alice", + source = "self", + petname = "Personal Email", + self = true, + ) ), - registerResult = RegisterResult.Idle, - onNavigateBack = {}, - onRegister = { _, _, _, _, _ -> }, - onComplete = { _, _, _, _, _ -> }, - onRecover = { _, _, _, _ -> }, - onDelete = { _, _, _, _ -> } - ) - } -} - -@Preview -@Composable -fun AliasRegistrationContentSuccessPreview() { - TalerSurface { - AliasRegistrationContent( - mailboxState = MailboxResult.Success( - MailboxConfiguration( - mailboxBaseUrl = "https://mailbox.taler.net", - privateKey = "", - privateEncryptionKey = "", - hAddress = "alice", - expiration = net.taler.common.Timestamp.fromMillis(0) - ), - emptyList() + mailboxConfig = MailboxConfiguration( + mailboxBaseUrl = "https://mailbox.taler.net", + privateKey = "", + privateEncryptionKey = "", + hAddress = "alice", + expiration = net.taler.common.Timestamp.fromMillis(0) ), - registerResult = RegisterResult.Success(validFor = null, targetUri = "https://mailbox.taler.net/alice"), + aliasStatuses = mapOf("email:alice@example.com" to AliasRegistrationStatus.Registered("https://mailbox.taler.net/alice")), + registerResult = RegisterResult.Idle, + activeRegistrationAlias = null, + isUnlinking = false, onNavigateBack = {}, - onRegister = { _, _, _, _, _ -> }, - onComplete = { _, _, _, _, _ -> }, - onRecover = { _, _, _, _ -> }, - onDelete = { _, _, _, _ -> } + onAddAliasClick = {}, + onStartRegister = {}, + onCompleteRegister = {}, + onRecoverRegister = {}, + onCancelRegister = {}, + onDeleteAlias = {}, + onRegistrationCompleted = {}, ) } } diff --git a/wallet/src/main/java/net/taler/wallet/contacts/ContactManager.kt b/wallet/src/main/java/net/taler/wallet/contacts/ContactManager.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import net.taler.common.RelativeTime @@ -35,11 +36,16 @@ import org.json.JSONObject data class ContactEntry( val alias: String, val aliasType: String, - val mailboxBaseUri: String, + @SerialName("mailboxBaseUrl") + val mailboxBaseUrl: String, val mailboxAddress: String, val source: String, val petname: String, -) + val self: Boolean = false, + val version: Int = 0, +) { + val mailboxBaseUri: String get() = mailboxBaseUrl +} @Serializable data class ContactListResponse( @@ -56,7 +62,8 @@ data class TaldirRegisterRequest( val alias: String, val aliasType: String, val taldirBaseUrl: String, - val targetUri: String, + val mailboxBaseUrl: String, + val mailboxAddress: String, val duration: RelativeTime, ) @@ -87,6 +94,13 @@ sealed class RegisterResult { data object CompletionSuccess : RegisterResult() } +sealed class AliasRegistrationStatus { + data object Checking : AliasRegistrationStatus() + data class Registered(val targetUri: String) : AliasRegistrationStatus() + data object NotRegistered : AliasRegistrationStatus() + data class Mismatched(val targetUri: String) : AliasRegistrationStatus() +} + class ContactManager( private val api: WalletBackendApi, private val scope: CoroutineScope, @@ -100,6 +114,9 @@ class ContactManager( private val _registerResult = MutableStateFlow<RegisterResult>(RegisterResult.Idle) val registerResult = _registerResult.asStateFlow() + private val _aliasStatuses = MutableStateFlow<Map<String, AliasRegistrationStatus>>(emptyMap()) + val aliasStatuses = _aliasStatuses.asStateFlow() + fun resetLookup() { _lookupResult.value = LookupResult.Idle } @@ -129,14 +146,18 @@ class ContactManager( mailboxAddress: String, source: String, petname: String, + self: Boolean = false, + version: Int = 0, ) { val entry = ContactEntry( alias = alias, aliasType = aliasType, - mailboxBaseUri = mailboxBaseUri, + mailboxBaseUrl = mailboxBaseUri, mailboxAddress = mailboxAddress, source = source, petname = petname, + self = self, + version = version, ) scope.launch { api.request<Unit>("addContact") { @@ -149,6 +170,44 @@ class ContactManager( } } + fun checkAliasRegistration( + alias: String, + aliasType: String, + currentMailboxBaseUrl: String, + currentMailboxAddress: String, + taldirBaseUri: String = "https://taldir.gnunet.org", + ) { + val key = "$aliasType:$alias" + val currentStatuses = _aliasStatuses.value.toMutableMap() + currentStatuses[key] = AliasRegistrationStatus.Checking + _aliasStatuses.value = currentStatuses + + scope.launch { + api.request<LookupAliasResponse>("lookupAlias", LookupAliasResponse.serializer()) { + put("alias", alias) + put("aliasType", aliasType) + put("taldirBaseUrl", taldirBaseUri) + }.onSuccess { resp -> + val targetUri = resp.targetUri ?: "" + val cleanBase = currentMailboxBaseUrl.trimEnd('/') + val expectedPath = "$cleanBase/$currentMailboxAddress" + val map = _aliasStatuses.value.toMutableMap() + if (targetUri.isBlank()) { + map[key] = AliasRegistrationStatus.NotRegistered + } else if (targetUri.trimEnd('/') == expectedPath || targetUri.endsWith(currentMailboxAddress)) { + map[key] = AliasRegistrationStatus.Registered(targetUri) + } else { + map[key] = AliasRegistrationStatus.Mismatched(targetUri) + } + _aliasStatuses.value = map + }.onError { + val map = _aliasStatuses.value.toMutableMap() + map[key] = AliasRegistrationStatus.NotRegistered + _aliasStatuses.value = map + } + } + } + fun taldirLookupAlias( alias: String, aliasType: String, @@ -194,7 +253,8 @@ class ContactManager( alias: String, aliasType: String, taldirBaseUri: String = "https://taldir.gnunet.org", - targetUri: String, + mailboxBaseUrl: String, + mailboxAddress: String, durationMonths: Int, ) { val durationUs = durationMonths.toLong() * 30 * 24 * 3600 * 1000000L @@ -204,10 +264,12 @@ class ContactManager( put("alias", alias) put("aliasType", aliasType) put("taldirBaseUrl", taldirBaseUri) - put("targetUri", targetUri) + put("mailboxBaseUrl", mailboxBaseUrl) + put("mailboxAddress", mailboxAddress) put("duration", JSONObject(BackendManager.json.encodeToString(RelativeTime.serializer(), RelativeTime(durationUs)))) }.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) }.onError { Log.e(TAG, "Error initiating Taldir registration: $it") @@ -220,7 +282,8 @@ class ContactManager( alias: String, aliasType: String, taldirBaseUri: String = "https://taldir.gnunet.org", - targetUri: String, + mailboxBaseUrl: String, + mailboxAddress: String, solution: String, ) { _registerResult.value = RegisterResult.CompletionLoading @@ -229,7 +292,8 @@ class ContactManager( put("alias", alias) put("aliasType", aliasType) put("taldirBaseUrl", taldirBaseUri) - put("targetUri", targetUri) + put("mailboxBaseUrl", mailboxBaseUrl) + put("mailboxAddress", mailboxAddress) put("challenge", solution) }.onSuccess { Log.d(TAG, "Taldir registration completed for $alias") @@ -245,13 +309,16 @@ class ContactManager( alias: String, aliasType: String, taldirBaseUri: String = "https://taldir.gnunet.org", - durationMonths: Int, + mailboxBaseUrl: String = "", + mailboxAddress: String = "", + durationMonths: Int = 12, ) { taldirRegister( alias = alias, aliasType = aliasType, taldirBaseUri = taldirBaseUri, - targetUri = "", + mailboxBaseUrl = mailboxBaseUrl, + mailboxAddress = mailboxAddress, durationMonths = durationMonths, ) } @@ -296,7 +363,12 @@ class ContactManager( } }.onError { Log.e(TAG, "Error recovering registration: $it") - _registerResult.value = RegisterResult.Error(it) + if (expectedTargetUri.isEmpty()) { + // When unlinking, lookup error (404 Alias not found) indicates successful unlinking + _registerResult.value = RegisterResult.CompletionSuccess + } else { + _registerResult.value = RegisterResult.Error(it) + } } } } diff --git a/wallet/src/main/java/net/taler/wallet/contacts/ContactScreen.kt b/wallet/src/main/java/net/taler/wallet/contacts/ContactScreen.kt @@ -539,7 +539,7 @@ fun ContactItemPreview() { contact = ContactEntry( alias = "alice@taler.net", aliasType = "email", - mailboxBaseUri = "https://mailbox.taler.net/", + mailboxBaseUrl = "https://mailbox.taler.net/", mailboxAddress = "alice", source = "manual", petname = "Alice", @@ -559,7 +559,7 @@ fun ContactListPreview() { ContactEntry( alias = "alice@taler.net", aliasType = "email", - mailboxBaseUri = "https://mailbox.taler.net/", + mailboxBaseUrl = "https://mailbox.taler.net/", mailboxAddress = "alice", source = "manual", petname = "Alice", @@ -567,7 +567,7 @@ fun ContactListPreview() { ContactEntry( alias = "bob@taler.net", aliasType = "email", - mailboxBaseUri = "https://mailbox.taler.net/", + mailboxBaseUrl = "https://mailbox.taler.net/", mailboxAddress = "bob", source = "manual", petname = "Bob", diff --git a/wallet/src/main/java/net/taler/wallet/mailbox/MailboxManager.kt b/wallet/src/main/java/net/taler/wallet/mailbox/MailboxManager.kt @@ -149,7 +149,7 @@ class MailboxManager( Log.d(TAG, "refreshing mailbox at ${config.mailboxBaseUrl}") scope.launch { api.request("refreshMailbox", MailboxMessagesResponse.serializer()) { - JSONObject(BackendManager.json.encodeToString(config)) + put("mailboxConf", JSONObject(BackendManager.json.encodeToString(config))) }.onSuccess { response -> if (response.messages.isNotEmpty()) { response.messages.forEach { msg -> diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -510,7 +510,7 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="alias_registration_exists">Registration already exists.</string> <string name="alias_registration_solution">Challenge Solution</string> <string name="alias_registration_success">Registration successful!</string> - <string name="alias_registration_title">Alias Registration</string> + <string name="alias_registration_title">Aliases</string> <string name="alias_registration_delete">Delete Alias</string> <string name="alias_registration_out_of_band">I have completed the registration out of band</string> <string name="contact_add_button">Add Contact</string>