taler-android

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

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:
Aflake.lock | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aflake.nix | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/build.gradle | 5++---
Mwallet/src/main/java/net/taler/wallet/HandleUriScreen.kt | 21++++++++++++++++++++-
Mwallet/src/main/java/net/taler/wallet/WalletNavHost.kt | 14++++++++++++++
Mwallet/src/main/java/net/taler/wallet/WalletNavigation.kt | 6++++++
Awallet/src/main/java/net/taler/wallet/contacts/ContactManager.kt | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/contacts/ContactScreen.kt | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/mailbox/MailboxManager.kt | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/mailbox/MailboxScreen.kt | 236+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/main/MainViewModel.kt | 4++++
Mwallet/src/main/java/net/taler/wallet/settings/SettingsScreen.kt | 15+++++++++++++++
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),