commit fa714d9dfaee7c441037713cce0486aece522601
parent 263d94aac8e771ea841ff3d91029f3d15fd95c86
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date: Tue, 14 Jul 2026 18:42:09 +0200
mailbox/taldir: first iteration of UIs
Diffstat:
12 files changed, 887 insertions(+), 4 deletions(-)
diff --git a/flake.lock b/flake.lock
@@ -0,0 +1,60 @@
+{
+ "nodes": {
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1784014140,
+ "narHash": "sha256-XoVFhgsyVd5zBXHm2US5w6GI9VC1Y0mfRr3I98Ge2kw=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "96f74a7199df92dfe5b67ebd9703e159e7300a99",
+ "type": "github"
+ },
+ "original": {
+ "id": "nixpkgs",
+ "ref": "release-26.05",
+ "type": "indirect"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-utils": "flake-utils",
+ "nixpkgs": "nixpkgs"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
@@ -0,0 +1,66 @@
+{
+ description = "Android Studio Development Environment for Taler Android";
+
+ inputs = {
+ nixpkgs.url = "nixpkgs/release-26.05";
+ flake-utils.url = "github:numtide/flake-utils";
+ };
+
+ outputs = { self, nixpkgs, flake-utils }:
+ flake-utils.lib.eachDefaultSystem (system:
+ let
+ pkgs = import nixpkgs {
+ inherit system;
+ config = {
+ allowUnfree = true;
+ android_sdk.accept_license = true;
+ };
+ };
+
+ # Composed SDK specifically covering Taler Android parameters (up to API 36)
+ androidComposition = pkgs.androidenv.composeAndroidPackages {
+ cmdLineToolsVersion = "13.0";
+ platformToolsVersion = "35.0.2";
+ buildToolsVersions = [ "34.0.0" "35.0.0" "36.0.0" "37.0.0" ];
+ platformVersions = [ "34" "35" "36" "37" ];
+ includeEmulator = true;
+ includeSystemImages = true;
+ systemImageTypes = [ "google_apis_playstore" ];
+ abiVersions = [ "x86_64" "arm64-v8a" ];
+
+ includeNDK = true;
+ ndkVersions = [ "29.0.14206865" ];
+ };
+ in
+ {
+ devShells.default = pkgs.mkShell {
+ buildInputs = [
+ pkgs.android-studio
+ androidComposition.androidsdk
+ pkgs.jdk21 # Taler builds utilize JDK 21
+ pkgs.nodejs # Required for fetching/processing wallet-core JS modules
+ pkgs.git
+ pkgs.steam-run
+ pkgs.libxcb
+ pkgs.libxcb-cursor
+ ];
+
+ shellHook = ''
+ export ANDROID_HOME="${androidComposition.androidsdk}/libexec/android-sdk"
+ export ANDROID_SDK_ROOT="$ANDROID_HOME"
+ export ANDROID_NDK_ROOT="$ANDROID_HOME/ndk-bundle"
+ export JAVA_HOME="${pkgs.jdk21.home}"
+
+ export PATH="$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
+ export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath [ pkgs.xorg.libxcb pkgs.libxcb-cursor ]}:$LD_LIBRARY_PATH"
+ # Auto-generate local.properties to enforce Nix paths over system ones
+ echo "sdk.dir=$ANDROID_HOME" > local.properties
+ echo "ndk.dir=$ANDROID_NDK_ROOT" >> local.properties
+
+ echo "🤖 Taler Android Wallet Shell Active!"
+ echo "Run './gradlew :wallet:assembleDebug' to build just the wallet."
+ '';
+ };
+ }
+ );
+}
diff --git a/wallet/build.gradle b/wallet/build.gradle
@@ -24,7 +24,7 @@ plugins {
id "org.jetbrains.kotlin.plugin.compose" version "$kotlin_version"
}
-def qtart_version = "1.6.42"
+def qtart_version = "1.6.5"
static def versionCodeEpoch() {
return (new Date().getTime() / 1000).toInteger()
@@ -219,4 +219,4 @@ protobuf {
}
}
}
-}
-\ No newline at end of file
+}
diff --git a/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt b/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt
@@ -125,6 +125,26 @@ fun HandleUriScreen(
action.startsWith("pay-template/", ignoreCase = true) -> {
onNavigate(WalletDestination.PromptPayTemplate(u2), true)
}
+ action.startsWith("add-contact/", ignoreCase = true) -> {
+ val uri = u2.toUri()
+ val segments = uri.pathSegments
+ val mailboxBaseUri = segments.getOrNull(0) ?: ""
+ val mailboxAddress = segments.getOrNull(1) ?: ""
+ val petname = uri.getQueryParameter("petname") ?: ""
+ val alias = uri.getQueryParameter("alias") ?: ""
+ val aliasType = uri.getQueryParameter("aliasType") ?: ""
+ val source = uri.getQueryParameter("source") ?: u2
+
+ model.contactManager.addContact(
+ alias = alias,
+ aliasType = aliasType,
+ mailboxBaseUri = mailboxBaseUri,
+ mailboxAddress = mailboxAddress,
+ source = source,
+ petname = petname
+ )
+ onNavigate(WalletDestination.Contacts, true)
+ }
action.startsWith("dev-experiment/", ignoreCase = true) -> {
model.applyDevExperiment(u2) { error ->
errorInfo = error
@@ -133,7 +153,6 @@ fun HandleUriScreen(
}
else -> {
errorInfo = TalerErrorInfo.makeCustomError("Unsupported URI: $u2")
- }
}
} catch (_: CancellationException) {
}
diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt b/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt
@@ -37,6 +37,8 @@ import androidx.navigation.toRoute
import net.taler.wallet.accounts.AddBankAccountScreen
import net.taler.wallet.accounts.BankAccountsScreen
import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.contacts.ContactScreen
+import net.taler.wallet.mailbox.MailboxScreen
import net.taler.wallet.deposit.DepositScreen
import net.taler.wallet.deposit.PayToUriScreen
import net.taler.wallet.donau.DonauStatementScreen
@@ -367,5 +369,17 @@ fun WalletNavHost(
onNavigateBack = onNavigateBack,
)
}
+ composable<WalletDestination.Contacts> {
+ ContactScreen(
+ model = model,
+ onNavigateBack = onNavigateBack,
+ )
+ }
+ composable<WalletDestination.Mailbox> {
+ MailboxScreen(
+ model = model,
+ onNavigateBack = onNavigateBack,
+ )
+ }
}
}
\ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt b/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt
@@ -104,6 +104,12 @@ sealed interface WalletDestination {
@Serializable
data object PerformanceStats : WalletDestination
+ @Serializable
+ data object Contacts : WalletDestination
+
+ @Serializable
+ data object Mailbox : WalletDestination
+
// Transaction Details
@Serializable
data object TransactionWithdrawal : WalletDestination
diff --git a/wallet/src/main/java/net/taler/wallet/contacts/ContactManager.kt b/wallet/src/main/java/net/taler/wallet/contacts/ContactManager.kt
@@ -0,0 +1,112 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.contacts
+
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.encodeToString
+import net.taler.wallet.backend.BackendManager
+import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.backend.WalletBackendApi
+import net.taler.wallet.main.TAG
+import org.json.JSONObject
+
+@Serializable
+data class ContactEntry(
+ val alias: String,
+ val aliasType: String,
+ val mailboxBaseUri: String,
+ val mailboxAddress: String,
+ val source: String,
+ val petname: String,
+)
+
+@Serializable
+data class ContactListResponse(
+ val contacts: List<ContactEntry>,
+)
+
+sealed class ContactResult {
+ data object Loading : ContactResult()
+ data class Error(val error: TalerErrorInfo) : ContactResult()
+ data class Success(val contacts: List<ContactEntry>) : ContactResult()
+}
+
+class ContactManager(
+ private val api: WalletBackendApi,
+ private val scope: CoroutineScope,
+) {
+ private val _contacts = MutableStateFlow<ContactResult>(ContactResult.Loading)
+ val contacts = _contacts.asStateFlow()
+
+ fun loadContacts() {
+ _contacts.value = ContactResult.Loading
+ scope.launch {
+ api.request("getContacts", ContactListResponse.serializer())
+ .onSuccess {
+ _contacts.value = ContactResult.Success(it.contacts)
+ }
+ .onError {
+ Log.e(TAG, "Error getting contacts: $it")
+ _contacts.value = ContactResult.Error(it)
+ }
+ }
+ }
+
+ fun addContact(
+ alias: String,
+ aliasType: String,
+ mailboxBaseUri: String,
+ mailboxAddress: String,
+ source: String,
+ petname: String,
+ ) {
+ val entry = ContactEntry(
+ alias = alias,
+ aliasType = aliasType,
+ mailboxBaseUri = mailboxBaseUri,
+ mailboxAddress = mailboxAddress,
+ source = source,
+ petname = petname,
+ )
+ scope.launch {
+ api.request<Unit>("addContact") {
+ put("contact", JSONObject(BackendManager.json.encodeToString(entry)))
+ }.onSuccess {
+ loadContacts()
+ }.onError {
+ Log.e(TAG, "Error adding contact: $it")
+ }
+ }
+ }
+
+ fun deleteContact(contact: ContactEntry) {
+ scope.launch {
+ api.request<Unit>("deleteContact") {
+ put("contact", JSONObject(BackendManager.json.encodeToString(contact)))
+ }.onSuccess {
+ loadContacts()
+ }.onError {
+ Log.e(TAG, "Error deleting contact: $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
@@ -0,0 +1,197 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.contacts
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+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.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+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.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.res.stringResource
+import androidx.compose.ui.unit.dp
+import net.taler.wallet.R
+import net.taler.wallet.main.MainViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ContactScreen(
+ model: MainViewModel,
+ onNavigateBack: () -> Unit,
+) {
+ val contactManager = model.contactManager
+ val contactResult by contactManager.contacts.collectAsState()
+ var showAddDialog by remember { mutableStateOf(false) }
+
+ LaunchedEffect(Unit) {
+ contactManager.loadContacts()
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Contacts") },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = null,
+ )
+ }
+ }
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(onClick = { showAddDialog = true }) {
+ Icon(Icons.Default.Add, contentDescription = "Add Contact")
+ }
+ }
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ ) {
+ when (val res = contactResult) {
+ is ContactResult.Loading -> Text("Loading...", modifier = Modifier.padding(16.dp))
+ is ContactResult.Error -> Text("Error: ${res.error}", modifier = Modifier.padding(16.dp))
+ is ContactResult.Success -> {
+ ContactList(res.contacts) { contact ->
+ contactManager.deleteContact(contact)
+ }
+ }
+ }
+ }
+ }
+
+ if (showAddDialog) {
+ AddContactDialog(
+ onDismiss = { showAddDialog = false },
+ onAdd = { alias, aliasType, mailboxBaseUri, mailboxAddress, source, petname ->
+ contactManager.addContact(alias, aliasType, mailboxBaseUri, mailboxAddress, source, petname)
+ showAddDialog = false
+ }
+ )
+ }
+}
+
+@Composable
+fun AddContactDialog(
+ onDismiss: () -> Unit,
+ onAdd: (String, String, String, String, String, String) -> Unit,
+) {
+ 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("") }
+
+ 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") })
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = { onAdd(alias, aliasType, mailboxBaseUri, mailboxAddress, source, petname) },
+ enabled = alias.isNotBlank() && petname.isNotBlank()
+ ) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ )
+}
+
+@Composable
+fun ContactList(
+ contacts: List<ContactEntry>,
+ onDelete: (ContactEntry) -> Unit,
+) {
+ if (contacts.isEmpty()) {
+ Text("No contacts yet.", modifier = Modifier.padding(16.dp))
+ } else {
+ LazyColumn {
+ items(contacts) { contact ->
+ ContactItem(contact, onDelete)
+ HorizontalDivider()
+ }
+ }
+ }
+}
+
+@Composable
+fun ContactItem(
+ contact: ContactEntry,
+ onDelete: (ContactEntry) -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(contact.petname, style = MaterialTheme.typography.titleMedium)
+ Text(contact.alias, style = MaterialTheme.typography.bodyMedium)
+ Text(contact.mailboxBaseUri, style = MaterialTheme.typography.bodySmall)
+ }
+ IconButton(onClick = { onDelete(contact) }) {
+ Icon(Icons.Default.Delete, contentDescription = "Delete")
+ }
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/mailbox/MailboxManager.kt b/wallet/src/main/java/net/taler/wallet/mailbox/MailboxManager.kt
@@ -0,0 +1,155 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.mailbox
+
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.serialization.Serializable
+import net.taler.common.Timestamp
+import net.taler.wallet.backend.BackendManager
+import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.backend.WalletBackendApi
+import net.taler.wallet.main.TAG
+import org.json.JSONObject
+
+@Serializable
+data class MailboxConfiguration(
+ val mailboxBaseUrl: String,
+ val privateKey: String,
+ val privateEncryptionKey: String,
+ val hAddress: String,
+ val expiration: Timestamp,
+ val payUri: String? = null,
+)
+
+@Serializable
+data class MailboxMessageRecord(
+ val originMailboxBaseUrl: String,
+ val downloadedAt: Timestamp,
+ val talerUri: String,
+)
+
+@Serializable
+data class MailboxMessagesResponse(
+ val messages: List<MailboxMessageRecord>,
+)
+
+@Serializable
+data class GetMailboxResponse(
+ val mailboxConfiguration: MailboxConfiguration? = null,
+)
+
+sealed class MailboxResult {
+ data object Loading : MailboxResult()
+ data class Error(val error: TalerErrorInfo) : MailboxResult()
+ data class Success(
+ val config: MailboxConfiguration,
+ val messages: List<MailboxMessageRecord>,
+ ) : MailboxResult()
+}
+
+class MailboxManager(
+ private val api: WalletBackendApi,
+ private val scope: CoroutineScope,
+) {
+ private val _mailbox = MutableStateFlow<MailboxResult>(MailboxResult.Loading)
+ val mailbox = _mailbox.asStateFlow()
+
+ fun loadMailbox(baseUrl: String = "https://mailbox.gnunet.org") {
+ _mailbox.value = MailboxResult.Loading
+ Log.e(TAG, "Loding mailbox")
+ scope.launch {
+ Log.e(TAG, "Loading mailbox")
+ api.request("getMailbox", GetMailboxResponse.serializer()) {
+ put("mailboxBaseUrl", baseUrl)
+ }.onSuccess { resp ->
+ if (null == resp.mailboxConfiguration) {
+ initializeMailbox(baseUrl)
+ } else {
+ getMessages(resp.mailboxConfiguration)
+ }
+ }.onError {
+ initializeMailbox(baseUrl)
+ }
+ }
+ }
+
+ private fun initializeMailbox(baseUrl: String) {
+ scope.launch {
+ api.request("initializeMailbox", MailboxConfiguration.serializer()) {
+ put("mailboxBaseUrl", baseUrl)
+ }.onSuccess { config ->
+ getMessages(config)
+ }.onError {
+ Log.e(TAG, "Error initializing mailbox: $it")
+ _mailbox.value = MailboxResult.Error(it)
+ }
+ }
+ }
+
+ private fun getMessages(config: MailboxConfiguration) {
+ scope.launch {
+ api.request("getMailboxMessage", MailboxMessagesResponse.serializer())
+ .onSuccess { response ->
+ _mailbox.value = MailboxResult.Success(config, response.messages)
+ }.onError {
+ Log.e(TAG, "Error getting mailbox messages: $it")
+ _mailbox.value = MailboxResult.Error(it)
+ }
+ }
+ }
+
+ fun refreshMailbox(config: MailboxConfiguration) {
+ scope.launch {
+ api.request("refreshMailbox", MailboxMessagesResponse.serializer()) {
+ JSONObject(BackendManager.json.encodeToString(config))
+ }.onSuccess { response ->
+ response.messages.forEach { msg ->
+ addMessage(msg)
+ }
+ getMessages(config)
+ }.onError {
+ Log.e(TAG, "Error refreshing mailbox: $it")
+ }
+ }
+ }
+
+ private fun addMessage(message: MailboxMessageRecord) {
+ scope.launch {
+ api.request<Unit>("addMailboxMessage") {
+ put("message", JSONObject(BackendManager.json.encodeToString(message)))
+ }.onError {
+ Log.e(TAG, "Error adding mailbox message: $it")
+ }
+ }
+ }
+
+ fun deleteMessage(config: MailboxConfiguration, message: MailboxMessageRecord) {
+ scope.launch {
+ api.request<Unit>("deleteMailboxMessage") {
+ put("message", JSONObject(BackendManager.json.encodeToString(message)))
+ }.onSuccess {
+ getMessages(config)
+ }.onError {
+ Log.e(TAG, "Error deleting mailbox message: $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
@@ -0,0 +1,236 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.mailbox
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+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.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.Delete
+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.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+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.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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.unit.dp
+import kotlinx.coroutines.launch
+import net.taler.lib.android.toAbsoluteTime
+import net.taler.wallet.R
+import net.taler.wallet.main.MainViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MailboxScreen(
+ model: MainViewModel,
+ onNavigateBack: () -> Unit,
+) {
+ val mailboxManager = model.mailboxManager
+ val mailboxState by mailboxManager.mailbox.collectAsState()
+ val snackbarHostState = remember { SnackbarHostState() }
+ val scope = rememberCoroutineScope()
+ val clipboardManager = LocalClipboardManager.current
+ var showUrlDialog by remember { mutableStateOf(false) }
+
+ LaunchedEffect(Unit) {
+ mailboxManager.loadMailbox()
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Mailbox") },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = null,
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = { showUrlDialog = true }) {
+ Icon(Icons.Default.Settings, contentDescription = "Configure Mailbox")
+ }
+ val state = mailboxState
+ if (state is MailboxResult.Success) {
+ IconButton(onClick = { mailboxManager.refreshMailbox(state.config) }) {
+ Icon(Icons.Default.Refresh, contentDescription = "Refresh")
+ }
+ }
+ }
+ )
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) }
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ ) {
+ when (val res = mailboxState) {
+ 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 -> {
+ val host = res.config.mailboxBaseUrl.removePrefix("https://").removePrefix("http://").trimEnd('/')
+ val contactUri = "taler://add-contact/$host/${res.config.hAddress}"
+ MailboxHeader(
+ address = contactUri,
+ onCopy = {
+ clipboardManager.setText(AnnotatedString(contactUri))
+ scope.launch {
+ snackbarHostState.showSnackbar("Contact URI copied to clipboard")
+ }
+ }
+ )
+ HorizontalDivider()
+ MessageList(res.messages) { message ->
+ mailboxManager.deleteMessage(res.config, message)
+ }
+ }
+ }
+ }
+ }
+
+ if (showUrlDialog) {
+ var url by remember { mutableStateOf("https://mailbox.gnunet.org") }
+ AlertDialog(
+ onDismissRequest = { showUrlDialog = false },
+ title = { Text("Configure Mailbox URL") },
+ text = {
+ OutlinedTextField(
+ value = url,
+ onValueChange = { url = it },
+ label = { Text("Mailbox Base URL") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ },
+ confirmButton = {
+ Button(onClick = {
+ mailboxManager.loadMailbox(url)
+ showUrlDialog = false
+ }) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showUrlDialog = false }) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ )
+ }
+}
+
+@Composable
+fun MailboxHeader(
+ address: String,
+ onCopy: () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onCopy() }
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("Your Mailbox Address", style = MaterialTheme.typography.labelMedium)
+ Text(address, style = MaterialTheme.typography.bodyMedium)
+ }
+ IconButton(onClick = onCopy) {
+ Icon(Icons.Default.ContentCopy, contentDescription = "Copy")
+ }
+ }
+}
+
+@Composable
+fun MessageList(
+ messages: List<MailboxMessageRecord>,
+ onDelete: (MailboxMessageRecord) -> Unit,
+) {
+ if (messages.isEmpty()) {
+ Text("No messages yet.", modifier = Modifier.padding(16.dp))
+ } else {
+ LazyColumn {
+ items(messages) { message ->
+ MessageItem(message, onDelete)
+ HorizontalDivider()
+ }
+ }
+ }
+}
+
+@Composable
+fun MessageItem(
+ message: MailboxMessageRecord,
+ onDelete: (MailboxMessageRecord) -> Unit,
+) {
+ val context = LocalContext.current
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(message.talerUri, style = MaterialTheme.typography.bodyMedium)
+ Text(
+ "From: ${message.originMailboxBaseUrl}",
+ style = MaterialTheme.typography.bodySmall
+ )
+ Text(
+ "Received: ${message.downloadedAt.ms.toAbsoluteTime(context)}",
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ IconButton(onClick = { onDelete(message) }) {
+ Icon(Icons.Default.Delete, contentDescription = "Delete")
+ }
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt
@@ -59,7 +59,9 @@ import net.taler.wallet.BuildConfig
import net.taler.wallet.NetworkManager
import net.taler.wallet.backend.InitResponse
import net.taler.wallet.backend.MigrateDatabaseResponse
+import net.taler.wallet.contacts.ContactManager
import net.taler.wallet.donau.DonauManager
+import net.taler.wallet.mailbox.MailboxManager
import net.taler.wallet.tokens.TokenManager
import java.util.UUID
@@ -113,6 +115,8 @@ class MainViewModel(
val depositManager: DepositManager = DepositManager(api, viewModelScope, balanceManager)
val tokenManager: TokenManager = TokenManager(api)
val donauManager: DonauManager = DonauManager(api, viewModelScope, exchangeManager)
+ val contactManager: ContactManager = ContactManager(api, viewModelScope)
+ val mailboxManager: MailboxManager = MailboxManager(api, viewModelScope)
private val mAuthenticated = MutableStateFlow(false)
val authenticated: StateFlow<Boolean> = mAuthenticated
diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsScreen.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsScreen.kt
@@ -52,6 +52,7 @@ import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.DomainAdd
import androidx.compose.material.icons.filled.LocalAtm
import androidx.compose.material.icons.filled.Memory
+import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@@ -239,6 +240,20 @@ fun SettingsScreen(
)
SettingsItem(
+ title = "Contacts",
+ summary = "Contacts",
+ icon = Icons.Default.Person,
+ onClick = { onNavigate(WalletDestination.Contacts, false) }
+ )
+
+ SettingsItem(
+ title = "Mailbox",
+ summary = "P2P Messaging",
+ icon = ImageVector.vectorResource(R.drawable.ic_adjust),
+ onClick = { onNavigate(WalletDestination.Mailbox, false) }
+ )
+
+ SettingsItem(
title = stringResource(R.string.settings_db_export),
summary = stringResource(R.string.settings_db_export_summary),
icon = ImageVector.vectorResource(R.drawable.ic_unarchive),