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