demo: display account addresses in receive tab

This commit is contained in:
Oscar Mira 2024-02-28 01:31:21 +01:00
parent 2a26407258
commit ec27aa42d4
13 changed files with 227 additions and 61 deletions

View File

@ -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,
)

View File

@ -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,
)
}
}
}
}
}
}

View File

@ -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<WalletAddress>,
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,
)
},
)

View File

@ -9,7 +9,6 @@ import im.molly.monero.demo.AppModule
import im.molly.monero.demo.common.Result import im.molly.monero.demo.common.Result
import im.molly.monero.demo.common.asResult import im.molly.monero.demo.common.asResult
import im.molly.monero.demo.data.WalletRepository import im.molly.monero.demo.data.WalletRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map

View File

@ -44,6 +44,10 @@ fun WalletRoute(
uiState = uiState, uiState = uiState,
onWalletConfigChange = { config -> viewModel.updateConfig(config) }, onWalletConfigChange = { config -> viewModel.updateConfig(config) },
onTransactionClick = onTransactionClick, onTransactionClick = onTransactionClick,
onCreateAccountClick = { viewModel.createAccount() },
onCreateSubAddressClick = { accountIndex ->
viewModel.createSubAddress(accountIndex)
},
onBackClick = onBackClick, onBackClick = onBackClick,
modifier = modifier, modifier = modifier,
) )
@ -54,6 +58,8 @@ private fun WalletScreen(
uiState: WalletUiState, uiState: WalletUiState,
onWalletConfigChange: (WalletConfig) -> Unit, onWalletConfigChange: (WalletConfig) -> Unit,
onTransactionClick: (String, Long) -> Unit, onTransactionClick: (String, Long) -> Unit,
onCreateAccountClick: () -> Unit,
onCreateSubAddressClick: (Int) -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -62,6 +68,8 @@ private fun WalletScreen(
uiState = uiState, uiState = uiState,
onWalletConfigChange = onWalletConfigChange, onWalletConfigChange = onWalletConfigChange,
onTransactionClick = onTransactionClick, onTransactionClick = onTransactionClick,
onCreateAccountClick = onCreateAccountClick,
onCreateSubAddressClick = onCreateSubAddressClick,
onBackClick = onBackClick, onBackClick = onBackClick,
modifier = modifier, modifier = modifier,
) )
@ -77,26 +85,32 @@ private fun WalletScreenLoaded(
uiState: WalletUiState.Loaded, uiState: WalletUiState.Loaded,
onWalletConfigChange: (WalletConfig) -> Unit, onWalletConfigChange: (WalletConfig) -> Unit,
onTransactionClick: (String, Long) -> Unit, onTransactionClick: (String, Long) -> Unit,
onCreateAccountClick: () -> Unit,
onCreateSubAddressClick: (Int) -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var showRenameDialog by remember { mutableStateOf(false) } var showRenameDialog by remember { mutableStateOf(false) }
Scaffold(topBar = { var selectedTabIndex by rememberSaveable { mutableStateOf(0) }
Toolbar(navigationIcon = {
IconButton(onClick = onBackClick) { Scaffold(
Icon( topBar = {
imageVector = AppIcons.ArrowBack, Toolbar(navigationIcon = {
contentDescription = "Back", IconButton(onClick = onBackClick) {
Icon(
imageVector = AppIcons.ArrowBack,
contentDescription = "Back",
)
}
}, actions = {
WalletKebabMenu(
onRenameClick = { showRenameDialog = true },
onDeleteClick = { },
) )
} })
}, actions = { },
WalletKebabMenu( ) { padding ->
onRenameClick = { showRenameDialog = true },
onDeleteClick = { },
)
})
}) { padding ->
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@ -107,14 +121,13 @@ private fun WalletScreenLoaded(
append(MoneroCurrency.SYMBOL + " ") append(MoneroCurrency.SYMBOL + " ")
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append( 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) Text(text = uiState.config.name, style = MaterialTheme.typography.headlineSmall)
var selectedTabIndex by rememberSaveable { mutableStateOf(0) }
WalletHeaderTabs( WalletHeaderTabs(
titles = listOf("Balance", "Send", "Receive", "History"), titles = listOf("Balance", "Send", "Receive", "History"),
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,
@ -132,19 +145,31 @@ private fun WalletScreenLoaded(
1 -> {} // TODO 1 -> {} // TODO
2 -> { 2 -> {
Column( val scrollState = rememberLazyListState()
modifier = modifier
.fillMaxWidth() LazyColumn(
.padding(16.dp) state = scrollState,
) { ) {
Text( addressCardItems(
text = "Primary address", items = uiState.addresses,
style = MaterialTheme.typography.labelMedium, onCreateSubAddressClick = onCreateSubAddressClick,
)
CopyableText(
text = uiState.config.publicAddress,
style = MaterialTheme.typography.bodyMedium,
) )
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, network = ledger.publicAddress.network,
balance = ledger.balance, balance = ledger.balance,
blockchainTime = ledger.checkedAt, blockchainTime = ledger.checkedAt,
addresses = emptyList(),
transactions = emptyList(), transactions = emptyList(),
), ),
onWalletConfigChange = {}, onWalletConfigChange = {},
onTransactionClick = { _: String, _: Long -> }, onTransactionClick = { _: String, _: Long -> },
onCreateAccountClick = {},
onCreateSubAddressClick = {},
onBackClick = {}, onBackClick = {},
) )
} }
} }
private class WalletScreenPreviewParameterProvider : PreviewParameterProvider<Ledger> { private class WalletScreenPreviewParameterProvider :
PreviewParameterProvider<Ledger> {
override val values = sequenceOf(PreviewParameterData.ledger) override val values = sequenceOf(PreviewParameterData.ledger)
} }

View File

@ -11,6 +11,7 @@ import im.molly.monero.demo.AppModule
import im.molly.monero.demo.common.Result import im.molly.monero.demo.common.Result
import im.molly.monero.demo.common.asResult import im.molly.monero.demo.common.asResult
import im.molly.monero.demo.data.WalletRepository 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.WalletConfig
import im.molly.monero.demo.data.model.WalletTransaction import im.molly.monero.demo.data.model.WalletTransaction
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@ -18,7 +19,7 @@ import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
class WalletViewModel( class WalletViewModel(
walletId: Long, private val walletId: Long,
private val walletRepository: WalletRepository = AppModule.walletRepository, private val walletRepository: WalletRepository = AppModule.walletRepository,
) : ViewModel() { ) : ViewModel() {
@ -46,6 +47,18 @@ class WalletViewModel(
walletRepository.updateWalletConfig(config) 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( private fun walletUiState(
@ -61,14 +74,27 @@ private fun walletUiState(
is Result.Success -> { is Result.Success -> {
val config = result.data.first val config = result.data.first
val ledger = result.data.second val ledger = result.data.second
val balance = ledger.balance val addresses =
val blockchainTime = ledger.checkedAt 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 = val transactions =
ledger.transactions ledger.transactions.map { WalletTransaction(config.id, it.value) }
.map { WalletTransaction(config.id, it.value) }
.sortedByDescending { it.transaction.blockTimestamp ?: Instant.MAX } .sortedByDescending { it.transaction.blockTimestamp ?: Instant.MAX }
val network = ledger.publicAddress.network WalletUiState.Loaded(
WalletUiState.Loaded(config, network, blockchainTime, balance, transactions) config = config,
network = ledger.publicAddress.network,
blockchainTime = ledger.checkedAt,
balance = ledger.balance,
addresses = addresses,
transactions = transactions,
)
} }
is Result.Loading -> { is Result.Loading -> {
@ -88,6 +114,7 @@ sealed interface WalletUiState {
val network: MoneroNetwork, val network: MoneroNetwork,
val blockchainTime: BlockchainTime, val blockchainTime: BlockchainTime,
val balance: Balance, val balance: Balance,
val addresses: List<WalletAddress>,
val transactions: List<WalletTransaction>, val transactions: List<WalletTransaction>,
) : WalletUiState ) : WalletUiState

View File

@ -16,6 +16,7 @@ import androidx.compose.ui.text.TextStyle
fun CopyableText( fun CopyableText(
text: String, text: String,
style: TextStyle, style: TextStyle,
modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
@ -23,7 +24,7 @@ fun CopyableText(
Text( Text(
text = text, text = text,
style = style, style = style,
modifier = Modifier.combinedClickable( modifier = modifier.combinedClickable(
onClick = {}, onClick = {},
onLongClick = { onLongClick = {
clipboardManager.setText(AnnotatedString(text)) clipboardManager.setText(AnnotatedString(text))

View File

@ -10,7 +10,7 @@ interface IWallet {
String getPublicAddress(); String getPublicAddress();
void addBalanceListener(in IBalanceListener listener); void addBalanceListener(in IBalanceListener listener);
void removeBalanceListener(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 createAccount(in IWalletCallbacks callback);
oneway void createSubAddressForAccount(int accountIndex, in IWalletCallbacks callback); oneway void createSubAddressForAccount(int accountIndex, in IWalletCallbacks callback);
oneway void getAllAddresses(in IWalletCallbacks callback); oneway void getAllAddresses(in IWalletCallbacks callback);

View File

@ -134,6 +134,14 @@ std::string FormatAccountAddress(
return ss.str(); 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() { std::string Wallet::createSubAddressAccount() {
return suspendRefreshAndRunLocked([&]() { return suspendRefreshAndRunLocked([&]() {
uint32_t index_major = m_wallet.get_num_subaddress_accounts(); 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 Wallet::addSubaddressInternal(const cryptonote::subaddress_index& index) {
std::string subaddress = m_wallet.get_subaddress_as_str(index); std::string subaddress = m_wallet.get_subaddress_as_str(index);
std::unique_lock<std::mutex> lock(m_subaddresses_mutex); std::unique_lock<std::mutex> lock(m_subaddresses_mutex);
@ -704,7 +704,7 @@ Java_im_molly_monero_WalletNative_nativeGetPublicAddress(
extern "C" extern "C"
JNIEXPORT jstring JNICALL JNIEXPORT jstring JNICALL
Java_im_molly_monero_WalletNative_nativeAddSubAddress( Java_im_molly_monero_WalletNative_nativeAddDetachedSubAddress(
JNIEnv* env, JNIEnv* env,
jobject thiz, jobject thiz,
jlong handle, jlong handle,
@ -712,7 +712,7 @@ Java_im_molly_monero_WalletNative_nativeAddSubAddress(
jint sub_address_minor) { jint sub_address_minor) {
auto* wallet = reinterpret_cast<Wallet*>(handle); auto* wallet = reinterpret_cast<Wallet*>(handle);
return NativeToJavaString( return NativeToJavaString(
env, wallet->addSubAddress(sub_address_major, sub_address_minor)); env, wallet->addDetachedSubAddress(sub_address_major, sub_address_minor));
} }
extern "C" extern "C"

View File

@ -94,9 +94,9 @@ class Wallet : i_wallet2_callback {
void cancelRefresh(); void cancelRefresh();
void setRefreshSince(long height_or_timestamp); void setRefreshSince(long height_or_timestamp);
std::string addDetachedSubAddress(uint32_t index_major, uint32_t index_minor);
std::string createSubAddressAccount(); std::string createSubAddressAccount();
std::string createSubAddress(uint32_t index_major); std::string createSubAddress(uint32_t index_major);
std::string addSubAddress(uint32_t index_major, uint32_t index_minor);
std::unique_ptr<PendingTransfer> createPayment( std::unique_ptr<PendingTransfer> createPayment(
const std::vector<std::string>& addresses, const std::vector<std::string>& addresses,

View File

@ -26,6 +26,16 @@ data class AccountAddress(
} }
} }
fun isAddressUsed(transactions: Iterable<Transaction>): Boolean {
return transactions.any { tx ->
tx.sent.any { enote ->
enote.owner == this
} || tx.received.any { enote ->
enote.owner == this
}
}
}
companion object { companion object {
fun parseWithIndexes(addressString: String): AccountAddress { fun parseWithIndexes(addressString: String): AccountAddress {
val parts = addressString.split("/") val parts = addressString.split("/")

View File

@ -5,9 +5,11 @@ import im.molly.monero.internal.consolidateTransactions
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.onFailure import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -25,9 +27,9 @@ class MoneroWallet internal constructor(
var dataStore by storageAdapter::dataStore var dataStore by storageAdapter::dataStore
suspend fun getOrCreateAddress(accountIndex: Int, subAddressIndex: Int): AccountAddress = suspend fun addDetachedSubAddress(accountIndex: Int, subAddressIndex: Int): AccountAddress =
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
wallet.getOrCreateAddress( wallet.addDetachedSubAddress(
accountIndex, accountIndex,
subAddressIndex, subAddressIndex,
object : BaseWalletCallbacks() { object : BaseWalletCallbacks() {
@ -91,14 +93,14 @@ class MoneroWallet internal constructor(
accountAddresses = accountAddresses, accountAddresses = accountAddresses,
blockchainContext = blockchainTime, blockchainContext = blockchainTime,
) )
lastKnownLedger = Ledger( val ledger = Ledger(
publicAddress = publicAddress, publicAddress = publicAddress,
accountAddresses = accountAddresses, accountAddresses = accountAddresses,
transactions = txById, transactions = txById,
enotes = enotes, enotes = enotes,
checkedAt = blockchainTime, checkedAt = blockchainTime,
) )
sendLedger(lastKnownLedger) sendLedger(ledger)
} }
override fun onRefresh(blockchainTime: BlockchainTime) { override fun onRefresh(blockchainTime: BlockchainTime) {
@ -114,16 +116,16 @@ class MoneroWallet internal constructor(
} }
private fun sendLedger(ledger: Ledger) { private fun sendLedger(ledger: Ledger) {
trySend(ledger).onFailure { lastKnownLedger = ledger
logger.e("Too many ledger updates, channel capacity exceeded", it) // Shouldn't block as we conflate the flow.
} trySendBlocking(ledger)
} }
} }
wallet.addBalanceListener(listener) wallet.addBalanceListener(listener)
awaitClose { wallet.removeBalanceListener(listener) } awaitClose { wallet.removeBalanceListener(listener) }
} }.conflate()
suspend fun awaitRefresh( suspend fun awaitRefresh(
ignoreMiningRewards: Boolean = true, ignoreMiningRewards: Boolean = true,

View File

@ -236,13 +236,13 @@ internal class WalletNative private constructor(
} }
} }
override fun getOrCreateAddress( override fun addDetachedSubAddress(
accountIndex: Int, accountIndex: Int,
subAddressIndex: Int, subAddressIndex: Int,
callback: IWalletCallbacks?, callback: IWalletCallbacks?,
) { ) {
scope.launch(ioDispatcher) { scope.launch(ioDispatcher) {
val subAddress = nativeAddSubAddress(handle, accountIndex, subAddressIndex) val subAddress = nativeAddDetachedSubAddress(handle, accountIndex, subAddressIndex)
notifyAddressCreation(subAddress, callback) notifyAddressCreation(subAddress, callback)
} }
} }
@ -370,7 +370,7 @@ internal class WalletNative private constructor(
const val REFRESH_ERROR: Int = 3 const val REFRESH_ERROR: Int = 3
} }
private external fun nativeAddSubAddress( private external fun nativeAddDetachedSubAddress(
handle: Long, handle: Long,
subAddressMajor: Int, subAddressMajor: Int,
subAddressMinor: Int, subAddressMinor: Int,