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

View File

@ -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<Ledger> {
private class WalletScreenPreviewParameterProvider :
PreviewParameterProvider<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.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<WalletAddress>,
val transactions: List<WalletTransaction>,
) : WalletUiState

View File

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

View File

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

View File

@ -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<std::mutex> 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<Wallet*>(handle);
return NativeToJavaString(
env, wallet->addSubAddress(sub_address_major, sub_address_minor));
env, wallet->addDetachedSubAddress(sub_address_major, sub_address_minor));
}
extern "C"

View File

@ -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<PendingTransfer> createPayment(
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 {
fun parseWithIndexes(addressString: String): AccountAddress {
val parts = addressString.split("/")

View File

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

View File

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