From ec27aa42d45f8557225095ba92891555a5e19f17 Mon Sep 17 00:00:00 2001 From: Oscar Mira Date: Wed, 28 Feb 2024 01:31:21 +0100 Subject: [PATCH] demo: display account addresses in receive tab --- .../monero/demo/data/model/WalletAddress.kt | 9 ++ .../im/molly/monero/demo/ui/AddressCard.kt | 64 ++++++++++++++ .../molly/monero/demo/ui/AddressCardList.kt | 25 ++++++ .../monero/demo/ui/TransactionViewModel.kt | 1 - .../im/molly/monero/demo/ui/WalletScreen.kt | 87 ++++++++++++------- .../molly/monero/demo/ui/WalletViewModel.kt | 41 +++++++-- .../monero/demo/ui/component/CopyableText.kt | 3 +- .../main/aidl/im/molly/monero/IWallet.aidl | 2 +- lib/android/src/main/cpp/wallet/wallet.cc | 20 ++--- lib/android/src/main/cpp/wallet/wallet.h | 2 +- .../kotlin/im/molly/monero/AccountAddress.kt | 10 +++ .../kotlin/im/molly/monero/MoneroWallet.kt | 18 ++-- .../kotlin/im/molly/monero/WalletNative.kt | 6 +- 13 files changed, 227 insertions(+), 61 deletions(-) create mode 100644 demo/android/src/main/kotlin/im/molly/monero/demo/data/model/WalletAddress.kt create mode 100644 demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddressCard.kt create mode 100644 demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddressCardList.kt diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/data/model/WalletAddress.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/data/model/WalletAddress.kt new file mode 100644 index 0000000..89b98b3 --- /dev/null +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/data/model/WalletAddress.kt @@ -0,0 +1,9 @@ +package im.molly.monero.demo.data.model + +import im.molly.monero.AccountAddress + +data class WalletAddress( + val address: AccountAddress, + val used: Boolean, + val isLastForAccount: Boolean, +) diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddressCard.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddressCard.kt new file mode 100644 index 0000000..a769242 --- /dev/null +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddressCard.kt @@ -0,0 +1,64 @@ +package im.molly.monero.demo.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.dp +import im.molly.monero.demo.data.model.WalletAddress +import im.molly.monero.demo.ui.component.CopyableText +import im.molly.monero.demo.ui.theme.Blue40 +import im.molly.monero.demo.ui.theme.Red40 + +@Composable +fun AddressCardExpanded( + walletAddress: WalletAddress, + onClick: () -> Unit, + onCreateSubAddressClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + with(walletAddress.address) { + val used = walletAddress.used || isPrimaryAddress + if (isPrimaryAddress) { + Text( + text = "Account #$accountIndex Primary address", + style = MaterialTheme.typography.labelMedium, + ) + } else { + Text( + text = "Account #$accountIndex Subaddress #$subAddressIndex", + style = MaterialTheme.typography.labelMedium, + ) + } + CopyableText( + text = address, + style = MaterialTheme.typography.bodyMedium, + modifier = if (used) Modifier.alpha(0.5f) else Modifier, + ) + if (walletAddress.isLastForAccount) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TextButton(onClick = onCreateSubAddressClick) { + Text( + text = "Add subaddress", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + } +} diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddressCardList.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddressCardList.kt new file mode 100644 index 0000000..e393da6 --- /dev/null +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddressCardList.kt @@ -0,0 +1,25 @@ +package im.molly.monero.demo.ui + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.ui.Modifier +import im.molly.monero.demo.data.model.WalletAddress + +fun LazyListScope.addressCardItems( + items: List, + onCreateSubAddressClick: (accountIndex: Int) -> Unit, + itemModifier: Modifier = Modifier, +) = items( + items = items, + key = { it.address }, + itemContent = { + AddressCardExpanded( + walletAddress = it, + onClick = { }, + onCreateSubAddressClick = { + onCreateSubAddressClick(it.address.accountIndex) + }, + modifier = itemModifier, + ) + }, +) diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/TransactionViewModel.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/TransactionViewModel.kt index 729aaca..b7eaf81 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/TransactionViewModel.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/TransactionViewModel.kt @@ -9,7 +9,6 @@ import im.molly.monero.demo.AppModule import im.molly.monero.demo.common.Result import im.molly.monero.demo.common.asResult import im.molly.monero.demo.data.WalletRepository -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletScreen.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletScreen.kt index 58fe2a2..36085e8 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletScreen.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletScreen.kt @@ -44,6 +44,10 @@ fun WalletRoute( uiState = uiState, onWalletConfigChange = { config -> viewModel.updateConfig(config) }, onTransactionClick = onTransactionClick, + onCreateAccountClick = { viewModel.createAccount() }, + onCreateSubAddressClick = { accountIndex -> + viewModel.createSubAddress(accountIndex) + }, onBackClick = onBackClick, modifier = modifier, ) @@ -54,6 +58,8 @@ private fun WalletScreen( uiState: WalletUiState, onWalletConfigChange: (WalletConfig) -> Unit, onTransactionClick: (String, Long) -> Unit, + onCreateAccountClick: () -> Unit, + onCreateSubAddressClick: (Int) -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -62,6 +68,8 @@ private fun WalletScreen( uiState = uiState, onWalletConfigChange = onWalletConfigChange, onTransactionClick = onTransactionClick, + onCreateAccountClick = onCreateAccountClick, + onCreateSubAddressClick = onCreateSubAddressClick, onBackClick = onBackClick, modifier = modifier, ) @@ -77,26 +85,32 @@ private fun WalletScreenLoaded( uiState: WalletUiState.Loaded, onWalletConfigChange: (WalletConfig) -> Unit, onTransactionClick: (String, Long) -> Unit, + onCreateAccountClick: () -> Unit, + onCreateSubAddressClick: (Int) -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { var showRenameDialog by remember { mutableStateOf(false) } - Scaffold(topBar = { - Toolbar(navigationIcon = { - IconButton(onClick = onBackClick) { - Icon( - imageVector = AppIcons.ArrowBack, - contentDescription = "Back", + var selectedTabIndex by rememberSaveable { mutableStateOf(0) } + + Scaffold( + topBar = { + Toolbar(navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = AppIcons.ArrowBack, + contentDescription = "Back", + ) + } + }, actions = { + WalletKebabMenu( + onRenameClick = { showRenameDialog = true }, + onDeleteClick = { }, ) - } - }, actions = { - WalletKebabMenu( - onRenameClick = { showRenameDialog = true }, - onDeleteClick = { }, - ) - }) - }) { padding -> + }) + }, + ) { padding -> Column( modifier = modifier .fillMaxSize() @@ -107,14 +121,13 @@ private fun WalletScreenLoaded( append(MoneroCurrency.SYMBOL + " ") withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append( - MoneroCurrency.Format(precision = 5).format(uiState.balance.confirmedAmount) + MoneroCurrency.Format(precision = 5) + .format(uiState.balance.confirmedAmount) ) } }) Text(text = uiState.config.name, style = MaterialTheme.typography.headlineSmall) - var selectedTabIndex by rememberSaveable { mutableStateOf(0) } - WalletHeaderTabs( titles = listOf("Balance", "Send", "Receive", "History"), selectedTabIndex = selectedTabIndex, @@ -132,19 +145,31 @@ private fun WalletScreenLoaded( 1 -> {} // TODO 2 -> { - Column( - modifier = modifier - .fillMaxWidth() - .padding(16.dp) + val scrollState = rememberLazyListState() + + LazyColumn( + state = scrollState, ) { - Text( - text = "Primary address", - style = MaterialTheme.typography.labelMedium, - ) - CopyableText( - text = uiState.config.publicAddress, - style = MaterialTheme.typography.bodyMedium, + addressCardItems( + items = uiState.addresses, + onCreateSubAddressClick = onCreateSubAddressClick, ) + item { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + OutlinedButton( + onClick = onCreateAccountClick, + modifier = modifier.padding(bottom = 16.dp), + ) { + Text( + text = "Create new account", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } } } @@ -286,15 +311,19 @@ private fun WalletScreenPopulated( network = ledger.publicAddress.network, balance = ledger.balance, blockchainTime = ledger.checkedAt, + addresses = emptyList(), transactions = emptyList(), ), onWalletConfigChange = {}, onTransactionClick = { _: String, _: Long -> }, + onCreateAccountClick = {}, + onCreateSubAddressClick = {}, onBackClick = {}, ) } } -private class WalletScreenPreviewParameterProvider : PreviewParameterProvider { +private class WalletScreenPreviewParameterProvider : + PreviewParameterProvider { override val values = sequenceOf(PreviewParameterData.ledger) } diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletViewModel.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletViewModel.kt index b6568af..d677093 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletViewModel.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletViewModel.kt @@ -11,6 +11,7 @@ import im.molly.monero.demo.AppModule import im.molly.monero.demo.common.Result import im.molly.monero.demo.common.asResult import im.molly.monero.demo.data.WalletRepository +import im.molly.monero.demo.data.model.WalletAddress import im.molly.monero.demo.data.model.WalletConfig import im.molly.monero.demo.data.model.WalletTransaction import kotlinx.coroutines.flow.* @@ -18,7 +19,7 @@ import kotlinx.coroutines.launch import java.time.Instant class WalletViewModel( - walletId: Long, + private val walletId: Long, private val walletRepository: WalletRepository = AppModule.walletRepository, ) : ViewModel() { @@ -46,6 +47,18 @@ class WalletViewModel( walletRepository.updateWalletConfig(config) } } + + fun createAccount() { + viewModelScope.launch { + walletRepository.getWallet(walletId).createAccount() + } + } + + fun createSubAddress(accountIndex: Int) { + viewModelScope.launch { + walletRepository.getWallet(walletId).createSubAddressForAccount(accountIndex) + } + } } private fun walletUiState( @@ -61,14 +74,27 @@ private fun walletUiState( is Result.Success -> { val config = result.data.first val ledger = result.data.second - val balance = ledger.balance - val blockchainTime = ledger.checkedAt + val addresses = + ledger.accountAddresses.groupBy { it.accountIndex }.flatMap { (_, group) -> + group.sortedBy { it.subAddressIndex }.mapIndexed { index, address -> + WalletAddress( + address = address, + used = address.isAddressUsed(ledger.transactions.values), + isLastForAccount = index == group.size - 1, + ) + } + } val transactions = - ledger.transactions - .map { WalletTransaction(config.id, it.value) } + ledger.transactions.map { WalletTransaction(config.id, it.value) } .sortedByDescending { it.transaction.blockTimestamp ?: Instant.MAX } - val network = ledger.publicAddress.network - WalletUiState.Loaded(config, network, blockchainTime, balance, transactions) + WalletUiState.Loaded( + config = config, + network = ledger.publicAddress.network, + blockchainTime = ledger.checkedAt, + balance = ledger.balance, + addresses = addresses, + transactions = transactions, + ) } is Result.Loading -> { @@ -88,6 +114,7 @@ sealed interface WalletUiState { val network: MoneroNetwork, val blockchainTime: BlockchainTime, val balance: Balance, + val addresses: List, val transactions: List, ) : WalletUiState diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/component/CopyableText.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/component/CopyableText.kt index c8bc34b..5c1832e 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/component/CopyableText.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/component/CopyableText.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.text.TextStyle fun CopyableText( text: String, style: TextStyle, + modifier: Modifier = Modifier, ) { val context = LocalContext.current val clipboardManager = LocalClipboardManager.current @@ -23,7 +24,7 @@ fun CopyableText( Text( text = text, style = style, - modifier = Modifier.combinedClickable( + modifier = modifier.combinedClickable( onClick = {}, onLongClick = { clipboardManager.setText(AnnotatedString(text)) diff --git a/lib/android/src/main/aidl/im/molly/monero/IWallet.aidl b/lib/android/src/main/aidl/im/molly/monero/IWallet.aidl index f1f496c..7186737 100644 --- a/lib/android/src/main/aidl/im/molly/monero/IWallet.aidl +++ b/lib/android/src/main/aidl/im/molly/monero/IWallet.aidl @@ -10,7 +10,7 @@ interface IWallet { String getPublicAddress(); void addBalanceListener(in IBalanceListener listener); void removeBalanceListener(in IBalanceListener listener); - oneway void getOrCreateAddress(int accountIndex, int subAddressIndex, in IWalletCallbacks callback); + oneway void addDetachedSubAddress(int accountIndex, int subAddressIndex, in IWalletCallbacks callback); oneway void createAccount(in IWalletCallbacks callback); oneway void createSubAddressForAccount(int accountIndex, in IWalletCallbacks callback); oneway void getAllAddresses(in IWalletCallbacks callback); diff --git a/lib/android/src/main/cpp/wallet/wallet.cc b/lib/android/src/main/cpp/wallet/wallet.cc index a7efda7..abfa844 100644 --- a/lib/android/src/main/cpp/wallet/wallet.cc +++ b/lib/android/src/main/cpp/wallet/wallet.cc @@ -134,6 +134,14 @@ std::string FormatAccountAddress( return ss.str(); } +std::string Wallet::addDetachedSubAddress(uint32_t index_major, uint32_t index_minor) { + return suspendRefreshAndRunLocked([&]() { + cryptonote::subaddress_index index = {index_major, index_minor}; + m_wallet.create_one_off_subaddress(index); + return addSubaddressInternal(index); + }); +} + std::string Wallet::createSubAddressAccount() { return suspendRefreshAndRunLocked([&]() { uint32_t index_major = m_wallet.get_num_subaddress_accounts(); @@ -150,14 +158,6 @@ std::string Wallet::createSubAddress(uint32_t index_major) { }); } -std::string Wallet::addSubAddress(uint32_t index_major, uint32_t index_minor) { - return suspendRefreshAndRunLocked([&]() { - cryptonote::subaddress_index index = {index_major, index_minor}; - m_wallet.create_one_off_subaddress(index); - return addSubaddressInternal(index); - }); -} - std::string Wallet::addSubaddressInternal(const cryptonote::subaddress_index& index) { std::string subaddress = m_wallet.get_subaddress_as_str(index); std::unique_lock lock(m_subaddresses_mutex); @@ -704,7 +704,7 @@ Java_im_molly_monero_WalletNative_nativeGetPublicAddress( extern "C" JNIEXPORT jstring JNICALL -Java_im_molly_monero_WalletNative_nativeAddSubAddress( +Java_im_molly_monero_WalletNative_nativeAddDetachedSubAddress( JNIEnv* env, jobject thiz, jlong handle, @@ -712,7 +712,7 @@ Java_im_molly_monero_WalletNative_nativeAddSubAddress( jint sub_address_minor) { auto* wallet = reinterpret_cast(handle); return NativeToJavaString( - env, wallet->addSubAddress(sub_address_major, sub_address_minor)); + env, wallet->addDetachedSubAddress(sub_address_major, sub_address_minor)); } extern "C" diff --git a/lib/android/src/main/cpp/wallet/wallet.h b/lib/android/src/main/cpp/wallet/wallet.h index 977a329..6fb67bf 100644 --- a/lib/android/src/main/cpp/wallet/wallet.h +++ b/lib/android/src/main/cpp/wallet/wallet.h @@ -94,9 +94,9 @@ class Wallet : i_wallet2_callback { void cancelRefresh(); void setRefreshSince(long height_or_timestamp); + std::string addDetachedSubAddress(uint32_t index_major, uint32_t index_minor); std::string createSubAddressAccount(); std::string createSubAddress(uint32_t index_major); - std::string addSubAddress(uint32_t index_major, uint32_t index_minor); std::unique_ptr createPayment( const std::vector& addresses, diff --git a/lib/android/src/main/kotlin/im/molly/monero/AccountAddress.kt b/lib/android/src/main/kotlin/im/molly/monero/AccountAddress.kt index 9376338..07fab92 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/AccountAddress.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/AccountAddress.kt @@ -26,6 +26,16 @@ data class AccountAddress( } } + fun isAddressUsed(transactions: Iterable): Boolean { + return transactions.any { tx -> + tx.sent.any { enote -> + enote.owner == this + } || tx.received.any { enote -> + enote.owner == this + } + } + } + companion object { fun parseWithIndexes(addressString: String): AccountAddress { val parts = addressString.split("/") diff --git a/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt b/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt index 4b870e1..6100973 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt @@ -5,9 +5,11 @@ import im.molly.monero.internal.consolidateTransactions import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.onFailure +import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flow import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.time.Duration.Companion.seconds @@ -25,9 +27,9 @@ class MoneroWallet internal constructor( var dataStore by storageAdapter::dataStore - suspend fun getOrCreateAddress(accountIndex: Int, subAddressIndex: Int): AccountAddress = + suspend fun addDetachedSubAddress(accountIndex: Int, subAddressIndex: Int): AccountAddress = suspendCancellableCoroutine { continuation -> - wallet.getOrCreateAddress( + wallet.addDetachedSubAddress( accountIndex, subAddressIndex, object : BaseWalletCallbacks() { @@ -91,14 +93,14 @@ class MoneroWallet internal constructor( accountAddresses = accountAddresses, blockchainContext = blockchainTime, ) - lastKnownLedger = Ledger( + val ledger = Ledger( publicAddress = publicAddress, accountAddresses = accountAddresses, transactions = txById, enotes = enotes, checkedAt = blockchainTime, ) - sendLedger(lastKnownLedger) + sendLedger(ledger) } override fun onRefresh(blockchainTime: BlockchainTime) { @@ -114,16 +116,16 @@ class MoneroWallet internal constructor( } private fun sendLedger(ledger: Ledger) { - trySend(ledger).onFailure { - logger.e("Too many ledger updates, channel capacity exceeded", it) - } + lastKnownLedger = ledger + // Shouldn't block as we conflate the flow. + trySendBlocking(ledger) } } wallet.addBalanceListener(listener) awaitClose { wallet.removeBalanceListener(listener) } - } + }.conflate() suspend fun awaitRefresh( ignoreMiningRewards: Boolean = true, diff --git a/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt b/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt index 7267d33..90273be 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt @@ -236,13 +236,13 @@ internal class WalletNative private constructor( } } - override fun getOrCreateAddress( + override fun addDetachedSubAddress( accountIndex: Int, subAddressIndex: Int, callback: IWalletCallbacks?, ) { scope.launch(ioDispatcher) { - val subAddress = nativeAddSubAddress(handle, accountIndex, subAddressIndex) + val subAddress = nativeAddDetachedSubAddress(handle, accountIndex, subAddressIndex) notifyAddressCreation(subAddress, callback) } } @@ -370,7 +370,7 @@ internal class WalletNative private constructor( const val REFRESH_ERROR: Int = 3 } - private external fun nativeAddSubAddress( + private external fun nativeAddDetachedSubAddress( handle: Long, subAddressMajor: Int, subAddressMinor: Int,