diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e206e75..1df0438 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { applicationId = "org.vextaproject.wallet" minSdk = 26 targetSdk = 36 - versionCode = 35 - versionName = "0.6.5" + versionCode = 36 + versionName = "0.6.6" } signingConfigs { diff --git a/app/src/main/java/org/vextaproject/wallet/BlockScanner.kt b/app/src/main/java/org/vextaproject/wallet/BlockScanner.kt index 3d815c1..f6a0c37 100644 --- a/app/src/main/java/org/vextaproject/wallet/BlockScanner.kt +++ b/app/src/main/java/org/vextaproject/wallet/BlockScanner.kt @@ -34,7 +34,11 @@ object BlockScanner { data class WalletTransaction( val txid: String, val height: Int, - val netSatoshis: Long + val netSatoshis: Long, + val blockTime: Long, + val isPending: Boolean = false, + val feeSatoshis: Long? = null, + val recipientAddress: String? = null ) data class Result( @@ -78,16 +82,26 @@ object BlockScanner { matchingHeights: List, walletScript: ByteArray, addressIndex: Int, + initialUtxos: List = emptyList(), progress: (Int, Int) -> Unit ): Result { if (matchingHeights.isEmpty()) { + val existing = + initialUtxos + .filter { it.addressIndex == addressIndex } + .sortedWith( + compareBy { it.height } + .thenBy { it.txid } + .thenBy { it.outputIndex } + ) + return Result( downloadedBlocks = 0, receivedTransactions = 0, spentTransactions = 0, - utxoCount = 0, - balanceSatoshis = 0, - utxos = emptyList(), + utxoCount = existing.size, + balanceSatoshis = existing.sumOf { it.value }, + utxos = existing, transactions = emptyList() ) } @@ -108,6 +122,26 @@ object BlockScanner { } val utxos = linkedMapOf() + + initialUtxos + .filter { it.addressIndex == addressIndex } + .forEach { existing -> + val txidWire = + existing.txid + .chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + .reversedArray() + + utxos[outPointKey(txidWire, existing.outputIndex)] = + Utxo( + txidWire = txidWire, + outputIndex = existing.outputIndex, + value = existing.value, + height = existing.height, + addressIndex = existing.addressIndex + ) + } var receivedTransactions = 0 var spentTransactions = 0 var downloadedBlocks = 0 @@ -145,6 +179,17 @@ object BlockScanner { } val blockHeader = payload.copyOfRange(0, 80) + + val blockTime = + ByteBuffer.wrap( + blockHeader, + 68, + 4 + ) + .order(ByteOrder.LITTLE_ENDIAN) + .int + .toLong() and 0xffffffffL + val blockHashWire = doubleSha256(blockHeader) val height = heightByHash[blockHashWire.toHex()] ?: throw IllegalStateException( @@ -212,7 +257,8 @@ object BlockScanner { .reversedArray() .toHex(), height = height, - netSatoshis = netSatoshis + netSatoshis = netSatoshis, + blockTime = blockTime ) ) } diff --git a/app/src/main/java/org/vextaproject/wallet/CompactFilterClient.kt b/app/src/main/java/org/vextaproject/wallet/CompactFilterClient.kt index dfa0143..0d859a3 100644 --- a/app/src/main/java/org/vextaproject/wallet/CompactFilterClient.kt +++ b/app/src/main/java/org/vextaproject/wallet/CompactFilterClient.kt @@ -128,6 +128,7 @@ object CompactFilterClient { context: Context, peer: String, scriptPubKey: ByteArray, + startHeight: Int = 1, progress: (Int, Int) -> Unit ): ScanResult { val headerHashes = loadHeaderHashes(context) @@ -151,7 +152,8 @@ object CompactFilterClient { var compactFiltersAvailable = false var gotVersion = false var gotVerack = false - var nextHeight = 1 + var nextHeight = + startHeight.coerceIn(1, localHeight) var requestedEndHeight = 0 var receivedInBatch = 0 var totalScanned = 0 @@ -239,7 +241,7 @@ object CompactFilterClient { requestedEndHeight == 0 ) { requestedEndHeight = minOf( - maxFiltersPerRequest, + nextHeight + maxFiltersPerRequest - 1, localHeight ) diff --git a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt index 35299f1..e6c823b 100644 --- a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt +++ b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt @@ -95,9 +95,25 @@ class WalletActivity : FragmentActivity() { "tx_notifications_initialized" private const val PREF_RECEIVE_ADDRESS_INDEX = "receive_address_index" + private const val PREF_ADDRESS_LABEL_PREFIX = + "receive_address_label_" private const val PREF_RESTORE_ADDRESS_DISCOVERY = "restore_address_discovery" private const val RESTORE_ADDRESS_LOOKAHEAD = 20 + private const val PREF_OUTGOING_TRANSACTIONS = + "outgoing_transactions" + private const val PREF_BACKGROUND_UTXO_PREFIX = + "background_utxos_" + private const val PREF_BACKGROUND_TX_HISTORY = + "background_transaction_history" + private const val PREF_LAST_BACKGROUND_SCAN_HEIGHT = + "last_background_scan_height" + + private const val CONTACT_PREFS = + "vexta_wallet_contacts" + + private const val PREF_CONTACTS_JSON = + "contacts_json" } private val backgroundColor = Color.rgb(2, 8, 23) @@ -126,6 +142,21 @@ class WalletActivity : FragmentActivity() { private var lastExitSwipeAt = 0L private var latestSpendableUtxos = emptyList() + private var latestChainHeight = 0 + + private data class OutgoingTransactionRecord( + val txid: String, + val amountSatoshis: Long, + val feeSatoshis: Long, + val recipientAddress: String, + val sentTime: Long + ) + + private data class WalletContact( + val id: String, + val name: String, + val address: String + ) private val refreshHandler = Handler(Looper.getMainLooper()) @@ -135,13 +166,37 @@ class WalletActivity : FragmentActivity() { !isFinishing && !isDestroyed && walletExists() && - !blockchainScanRunning + mainWalletVisible ) { - if (hasWindowFocus()) { - startHeaderSyncAndScan() - } else { - startBackgroundHeaderSyncAndScan() - } + val cachedUtxos = + loadCachedWalletUtxos() + + val cachedTransactions = + loadCachedWalletTransactions() + + latestSpendableUtxos = cachedUtxos + latestWalletTransactions = + cachedTransactions + + val balanceSatoshis = + cachedUtxos.sumOf { it.value } + + walletBalanceView?.text = + String.format( + "%.8f VTX", + balanceSatoshis.toDouble() / + 100_000_000.0 + ) + + walletHistoryView?.text = + formatTransactionHistory( + cachedTransactions, + limit = 3, + showFullTxid = false + ) + + blockchainScanStatus?.text = + "Wallet ready — background synchronization active" } refreshHandler.postDelayed(this, 60_000L) @@ -894,6 +949,169 @@ class WalletActivity : FragmentActivity() { super.onActivityResult(requestCode, resultCode, data) } + private fun addressLabel(index: Int): String { + return getSharedPreferences(PREFS, MODE_PRIVATE) + .getString( + PREF_ADDRESS_LABEL_PREFIX + index, + "" + ) + ?.trim() + .orEmpty() + } + + private fun setAddressLabel( + index: Int, + label: String + ) { + val editor = + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit() + + val value = label.trim() + + if (value.isBlank()) { + editor.remove( + PREF_ADDRESS_LABEL_PREFIX + index + ) + } else { + editor.putString( + PREF_ADDRESS_LABEL_PREFIX + index, + value + ) + } + + editor.apply() + } + + private fun loadCachedWalletUtxos(): List { + val preferences = + getSharedPreferences(PREFS, MODE_PRIVATE) + + val highestIndex = currentReceiveAddressIndex() + + return (0..highestIndex) + .flatMap { addressIndex -> + val values = + preferences.getStringSet( + PREF_BACKGROUND_UTXO_PREFIX + addressIndex, + emptySet() + ) ?: emptySet() + + values.mapNotNull { value -> + try { + val parts = value.split("|") + + if (parts.size != 5) { + return@mapNotNull null + } + + BlockScanner.SpendableUtxo( + txid = parts[0], + outputIndex = parts[1].toLong(), + value = parts[2].toLong(), + height = parts[3].toInt(), + addressIndex = parts[4].toInt() + ) + } catch (_: Exception) { + null + } + } + } + .distinctBy { + "${it.txid}:${it.outputIndex}" + } + } + + private fun loadCachedWalletTransactions(): + List { + + val preferences = + getSharedPreferences(PREFS, MODE_PRIVATE) + + val outgoingByTxid = + loadOutgoingTransactionRecords(preferences) + .associateBy { it.txid } + + val values = + preferences.getStringSet( + PREF_BACKGROUND_TX_HISTORY, + emptySet() + ) ?: emptySet() + + val confirmed = + values.mapNotNull { value -> + try { + val parts = value.split("|") + + if (parts.size != 4) { + return@mapNotNull null + } + + val txid = parts[0] + val outgoing = outgoingByTxid[txid] + + BlockScanner.WalletTransaction( + txid = txid, + height = parts[1].toInt(), + netSatoshis = + if (outgoing != null) { + -outgoing.amountSatoshis + } else { + parts[2].toLong() + }, + blockTime = parts[3].toLong(), + feeSatoshis = outgoing?.feeSatoshis, + recipientAddress = + outgoing?.recipientAddress + ) + } catch (_: Exception) { + null + } + } + + val confirmedTxids = + confirmed.map { it.txid }.toSet() + + val pending = + loadOutgoingTransactionRecords(preferences) + .filter { it.txid !in confirmedTxids } + .map { outgoing -> + BlockScanner.WalletTransaction( + txid = outgoing.txid, + height = 0, + netSatoshis = + -outgoing.amountSatoshis, + blockTime = outgoing.sentTime, + isPending = true, + feeSatoshis = + outgoing.feeSatoshis, + recipientAddress = + outgoing.recipientAddress + ) + } + + return (pending + confirmed) + .sortedWith( + compareByDescending< + BlockScanner.WalletTransaction + > { + if (it.isPending) { + Long.MAX_VALUE + } else { + it.blockTime + } + } + ) + } + + private fun hasCachedWalletState(): Boolean { + return getSharedPreferences(PREFS, MODE_PRIVATE) + .getInt( + PREF_LAST_BACKGROUND_SCAN_HEIGHT, + 0 + ) > 0 + } + private fun currentReceiveAddressIndex(): Int { return getSharedPreferences(PREFS, MODE_PRIVATE) .getInt(PREF_RECEIVE_ADDRESS_INDEX, 0) @@ -971,6 +1189,12 @@ class WalletActivity : FragmentActivity() { totalFiltersScanned += filterResult.scannedFilters + latestChainHeight = + maxOf( + latestChainHeight, + filterResult.localHeaderHeight + ) + val blockResult = BlockScanner.scan( this@WalletActivity, "87.106.99.23", @@ -1008,26 +1232,79 @@ class WalletActivity : FragmentActivity() { blockResult.spentTransactions } - val mergedTransactions = + val outgoingRecords = + loadOutgoingTransactionRecords(preferences) + + val outgoingByTxid = + outgoingRecords.associateBy { it.txid } + + val confirmedTransactions = allTransactions .groupBy { it.txid } .map { (_, transactions) -> - BlockScanner.WalletTransaction( - txid = transactions.first().txid, - height = transactions.maxOf { + val newest = + transactions.maxByOrNull { it.height - }, - netSatoshis = transactions.sumOf { - it.netSatoshis - } + } ?: transactions.first() + + val outgoing = + outgoingByTxid[newest.txid] + + BlockScanner.WalletTransaction( + txid = newest.txid, + height = newest.height, + netSatoshis = + if (outgoing != null) { + -outgoing.amountSatoshis + } else { + transactions.sumOf { + it.netSatoshis + } + }, + blockTime = newest.blockTime, + feeSatoshis = + outgoing?.feeSatoshis, + recipientAddress = + outgoing?.recipientAddress ) } .filter { it.netSatoshis != 0L } + + val confirmedTxids = + confirmedTransactions + .map { it.txid } + .toSet() + + val pendingTransactions = + outgoingRecords + .filter { it.txid !in confirmedTxids } + .map { outgoing -> + BlockScanner.WalletTransaction( + txid = outgoing.txid, + height = 0, + netSatoshis = + -outgoing.amountSatoshis, + blockTime = + outgoing.sentTime, + isPending = true, + feeSatoshis = + outgoing.feeSatoshis, + recipientAddress = + outgoing.recipientAddress + ) + } + + val mergedTransactions = + (pendingTransactions + confirmedTransactions) .sortedWith( compareByDescending< BlockScanner.WalletTransaction > { - it.height + if (it.isPending) { + Long.MAX_VALUE + } else { + it.blockTime + } }.thenByDescending { it.txid } @@ -1150,12 +1427,158 @@ class WalletActivity : FragmentActivity() { transaction.txid.takeLast(12) } + val dateTime = + java.text.SimpleDateFormat( + "dd/MM/yyyy HH:mm:ss", + java.util.Locale.getDefault() + ).format( + java.util.Date( + transaction.blockTime * 1000L + ) + ) + + val statusText = + if (transaction.isPending) { + "Pending\nConfirmations: 0" + } else { + val confirmations = + ( + latestChainHeight - + transaction.height + + 1 + ).coerceAtLeast(1) + + "Block ${transaction.height}\n" + + "Confirmations: $confirmations" + } + "$direction $sign$amount VTX\n" + - "Block ${transaction.height}\n" + + "$dateTime\n" + + "$statusText\n" + txidText } } + private fun showTransactionDetail( + transaction: BlockScanner.WalletTransaction + ) { + val received = transaction.netSatoshis > 0L + + val direction = + if (received) "Received" else "Sent" + + val amount = + String.format( + java.util.Locale.US, + "%.8f", + kotlin.math.abs( + transaction.netSatoshis + ).toDouble() / 100_000_000.0 + ) + + val dateTime = + java.text.SimpleDateFormat( + "dd/MM/yyyy HH:mm:ss", + java.util.Locale.getDefault() + ).format( + java.util.Date( + transaction.blockTime * 1000L + ) + ) + + val confirmations = + if (transaction.isPending) { + 0 + } else { + ( + latestChainHeight - + transaction.height + + 1 + ).coerceAtLeast(1) + } + + val details = StringBuilder() + + details.append("Type: $direction\n") + details.append("Amount: $amount VTX\n") + details.append("Date & time: $dateTime\n") + + if (transaction.isPending) { + details.append("Status: Pending\n") + details.append("Confirmations: 0\n") + } else { + details.append("Status: Confirmed\n") + details.append("Block: ${transaction.height}\n") + details.append("Confirmations: $confirmations\n") + } + + transaction.feeSatoshis?.let { fee -> + details.append( + "Network fee: " + + String.format( + java.util.Locale.US, + "%.8f VTX", + fee.toDouble() / 100_000_000.0 + ) + + "\n" + ) + } + + transaction.recipientAddress?.let { recipient -> + details.append("Recipient:\n$recipient\n") + } + + details.append("TXID:\n${transaction.txid}") + + AlertDialog.Builder(this) + .setTitle("Transaction details") + .setMessage(details.toString()) + .setNegativeButton("Close", null) + .setNeutralButton("Copy TXID") { _, _ -> + val clipboard = + getSystemService( + Context.CLIPBOARD_SERVICE + ) as ClipboardManager + + clipboard.setPrimaryClip( + ClipData.newPlainText( + "Vexta transaction ID", + transaction.txid + ) + ) + + Toast.makeText( + this, + "TXID copied", + Toast.LENGTH_SHORT + ).show() + } + .setPositiveButton("View in Explorer") { _, _ -> + val url = + "https://vextaproject.org/explorer/" + + "?view=tx&id=" + + android.net.Uri.encode( + transaction.txid + ) + + try { + startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse(url) + ) + ) + } catch (error: Exception) { + Toast.makeText( + this, + "Unable to open explorer", + Toast.LENGTH_LONG + ).show() + } + } + .show() + } + private fun showTransactionHistory() { mainWalletVisible = false transactionHistoryVisible = true @@ -1279,18 +1702,58 @@ class WalletActivity : FragmentActivity() { ) addView(space(10)) - addView( - smallStatus( - formatTransactionHistory( - displayedTransactions, - limit = displayedTransactions.size, - showFullTxid = true + if (displayedTransactions.isEmpty()) { + addView( + smallStatus( + "No transactions found" ) - ).apply { - gravity = Gravity.START - setTextIsSelectable(true) + ) + } else { + displayedTransactions.forEachIndexed { + index, + transaction -> + + addView( + smallStatus( + formatTransactionHistory( + listOf(transaction), + limit = 1, + showFullTxid = true + ) + ).apply { + gravity = Gravity.START + isClickable = true + isFocusable = true + + setPadding( + dp(12), + dp(12), + dp(12), + dp(12) + ) + + background = + roundedDrawable( + surfaceColorAlt, + 14 + ) + + setOnClickListener { + showTransactionDetail( + transaction + ) + } + } + ) + + if ( + index < + displayedTransactions.lastIndex + ) { + addView(space(10)) + } } - ) + } if ( filteredTransactions.size > @@ -1537,12 +2000,54 @@ class WalletActivity : FragmentActivity() { val addressIndex = currentReceiveAddressIndex() val address = deriveAddress(words, addressIndex) + val cachedUtxos = + loadCachedWalletUtxos() + + latestChainHeight = + getSharedPreferences(PREFS, MODE_PRIVATE) + .getInt( + PREF_LAST_BACKGROUND_SCAN_HEIGHT, + 0 + ) + .coerceAtLeast(0) + + val cachedTransactions = + loadCachedWalletTransactions() + + latestSpendableUtxos = cachedUtxos + latestWalletTransactions = cachedTransactions + + val cachedBalance = + cachedUtxos.sumOf { it.value } + + val cachedBalanceText = + String.format( + "%.8f VTX", + cachedBalance.toDouble() / + 100_000_000.0 + ) + val content = baseLayout() - val balanceView = headlineValue("0.00000000 VTX") + val balanceView = headlineValue(cachedBalanceText) walletBalanceView = balanceView - val compactFilterStatus = smallStatus("Compact filters: checking peers...") - val scanStatus = smallStatus("Wallet scan: not started") - val historyView = smallStatus("No confirmed transactions yet") + val compactFilterStatus = + smallStatus("Compact filters: checking peers...") + val scanStatus = + smallStatus( + if (hasCachedWalletState()) { + "Wallet ready — background synchronization active" + } else { + "Wallet scan: not started" + } + ) + val historyView = + smallStatus( + formatTransactionHistory( + cachedTransactions, + limit = 3, + showFullTxid = false + ) + ) blockchainScanStatus = scanStatus walletHistoryView = historyView @@ -1725,18 +2230,22 @@ class WalletActivity : FragmentActivity() { refreshHandler.removeCallbacks(refreshRunnable) refreshHandler.postDelayed(refreshRunnable, 60_000L) - automaticScanStarted = true + if (!automaticScanStarted) { + automaticScanStarted = true - window.decorView.postDelayed({ - if ( - !isFinishing && - !isDestroyed && - mainWalletVisible && - !blockchainScanRunning - ) { - startHeaderSyncAndScan() + if (!hasCachedWalletState()) { + window.decorView.postDelayed({ + if ( + !isFinishing && + !isDestroyed && + mainWalletVisible && + !blockchainScanRunning + ) { + startHeaderSyncAndScan() + } + }, 500L) } - }, 500L) + } Thread { @@ -1766,6 +2275,152 @@ class WalletActivity : FragmentActivity() { }.start() } + private fun showAddressLabelManager() { + mainWalletVisible = false + + val words = try { + loadMnemonic() + .trim() + .split(Regex("\\s+")) + } catch (error: Exception) { + showFatalError( + "Unable to load wallet: " + + (error.message ?: error.javaClass.simpleName) + ) + return + } + + val highestIndex = currentReceiveAddressIndex() + + val content = baseLayout() + content.addView(space(10)) + content.addView(centeredLogo(72)) + content.addView(space(14)) + content.addView(title("Address Labels")) + content.addView( + subtitle( + "Name your receive addresses for easier identification" + ) + ) + content.addView(space(20)) + + for (index in 0..highestIndex) { + val address = deriveAddress(words, index) + val label = addressLabel(index) + + content.addView( + card { + addView( + sectionTitle( + if (label.isBlank()) { + "Address ${index + 1}" + } else { + label + } + ) + ) + + addView(space(8)) + + addView( + smallStatus( + "Address ${index + 1}" + ) + ) + + addView(space(8)) + + addView( + infoBox( + address, + 14f, + Gravity.CENTER + ).apply { + isClickable = true + isFocusable = true + + setOnClickListener { + val clipboard = + getSystemService( + Context.CLIPBOARD_SERVICE + ) as ClipboardManager + + clipboard.setPrimaryClip( + ClipData.newPlainText( + "Vexta address", + address + ) + ) + + Toast.makeText( + this@WalletActivity, + "Address copied", + Toast.LENGTH_SHORT + ).show() + } + } + ) + + addView(space(10)) + + addView( + secondaryButton( + if (label.isBlank()) { + "Add label" + } else { + "Edit label" + } + ) { + val input = + EditText(this@WalletActivity).apply { + hint = + "e.g. Mining, Pool payout, Personal" + setText(label) + setSelection(text.length) + } + + AlertDialog.Builder( + this@WalletActivity + ) + .setTitle( + "Address ${index + 1} label" + ) + .setView(input) + .setNegativeButton( + "Cancel", + null + ) + .setNeutralButton( + "Remove" + ) { _, _ -> + setAddressLabel(index, "") + showAddressLabelManager() + } + .setPositiveButton( + "Save" + ) { _, _ -> + setAddressLabel( + index, + input.text.toString() + ) + showAddressLabelManager() + } + .show() + } + ) + } + ) + + content.addView(space(14)) + } + + setContentView( + scroll(content) { + showReceive() + } + ) + } + private fun showReceive() { mainWalletVisible = false window.clearFlags( @@ -1806,6 +2461,18 @@ class WalletActivity : FragmentActivity() { "Receive address ${addressIndex + 1}" ) ) + + val currentLabel = addressLabel(addressIndex) + + if (currentLabel.isNotBlank()) { + addView(space(8)) + addView( + smallStatus( + "Label: $currentLabel" + ) + ) + } + addView(space(14)) addView( @@ -1870,6 +2537,36 @@ class WalletActivity : FragmentActivity() { "Tap the address to copy it" ) ) + addView(space(12)) + addView( + secondaryButton("Edit address label") { + val input = + EditText(this@WalletActivity).apply { + hint = "e.g. Mining, Pool payout, Personal" + setText(addressLabel(addressIndex)) + setSelection(text.length) + } + + AlertDialog.Builder(this@WalletActivity) + .setTitle( + "Address ${addressIndex + 1} label" + ) + .setView(input) + .setNegativeButton("Cancel", null) + .setNeutralButton("Remove") { _, _ -> + setAddressLabel(addressIndex, "") + showReceive() + } + .setPositiveButton("Save") { _, _ -> + setAddressLabel( + addressIndex, + input.text.toString() + ) + showReceive() + } + .show() + } + ) } ) @@ -1879,6 +2576,12 @@ class WalletActivity : FragmentActivity() { card { addView(sectionTitle("Address management")) addView(space(12)) + addView( + secondaryButton("Manage address labels") { + showAddressLabelManager() + } + ) + addView(space(10)) addView( secondaryButton("Generate new address") { AlertDialog.Builder( @@ -1957,6 +2660,519 @@ class WalletActivity : FragmentActivity() { return false } + private fun saveOutgoingTransactionRecord( + record: OutgoingTransactionRecord + ) { + val preferences = + getSharedPreferences(PREFS, MODE_PRIVATE) + + val existing = + preferences.getStringSet( + PREF_OUTGOING_TRANSACTIONS, + emptySet() + )?.toMutableSet() ?: mutableSetOf() + + existing.removeAll { + it.substringBefore("|") == record.txid + } + + existing.add( + listOf( + record.txid, + record.amountSatoshis.toString(), + record.feeSatoshis.toString(), + record.recipientAddress, + record.sentTime.toString() + ).joinToString("|") + ) + + preferences.edit() + .putStringSet( + PREF_OUTGOING_TRANSACTIONS, + existing + ) + .apply() + } + + private fun loadOutgoingTransactionRecords( + preferences: android.content.SharedPreferences = + getSharedPreferences(PREFS, MODE_PRIVATE) + ): List { + val values = + preferences.getStringSet( + PREF_OUTGOING_TRANSACTIONS, + emptySet() + ) ?: emptySet() + + return values.mapNotNull { value -> + try { + val parts = value.split("|") + + if (parts.size != 5) { + return@mapNotNull null + } + + OutgoingTransactionRecord( + txid = parts[0], + amountSatoshis = parts[1].toLong(), + feeSatoshis = parts[2].toLong(), + recipientAddress = parts[3], + sentTime = parts[4].toLong() + ) + } catch (_: Exception) { + null + } + } + } + + private fun loadContacts(): List { + val preferences = + getSharedPreferences( + CONTACT_PREFS, + MODE_PRIVATE + ) + + val json = + preferences.getString( + PREF_CONTACTS_JSON, + "[]" + ) ?: "[]" + + return try { + val array = org.json.JSONArray(json) + + buildList { + for (index in 0 until array.length()) { + val item = + array.optJSONObject(index) + ?: continue + + val id = + item.optString("id").trim() + + val name = + item.optString("name").trim() + + val address = + item.optString("address").trim() + + if ( + id.isNotBlank() && + name.isNotBlank() && + address.isNotBlank() + ) { + add( + WalletContact( + id = id, + name = name, + address = address + ) + ) + } + } + }.sortedBy { + it.name.lowercase( + java.util.Locale.getDefault() + ) + } + } catch (_: Exception) { + emptyList() + } + } + + private fun saveContacts( + contacts: List + ) { + val array = org.json.JSONArray() + + contacts.forEach { contact -> + array.put( + org.json.JSONObject().apply { + put("id", contact.id) + put("name", contact.name) + put("address", contact.address) + } + ) + } + + getSharedPreferences( + CONTACT_PREFS, + MODE_PRIVATE + ) + .edit() + .putString( + PREF_CONTACTS_JSON, + array.toString() + ) + .apply() + } + + private fun showContactEditor( + recipientInput: EditText, + existing: WalletContact? = null + ) { + val content = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding( + dp(20), + dp(8), + dp(20), + dp(4) + ) + } + + val nameInput = + inputField( + hint = "Contact name", + singleLine = true + ).apply { + setText(existing?.name ?: "") + } + + val addressInput = + inputField( + hint = "Vexta address", + singleLine = false + ).apply { + setText(existing?.address ?: "") + } + + content.addView(nameInput) + content.addView(space(12)) + content.addView(addressInput) + + val dialog = + AlertDialog.Builder(this) + .setTitle( + if (existing == null) { + "Add contact" + } else { + "Edit contact" + } + ) + .setView(content) + .setNegativeButton("Cancel", null) + .setPositiveButton("Save", null) + .create() + + dialog.setOnShowListener { + dialog.getButton( + AlertDialog.BUTTON_POSITIVE + ).setOnClickListener { + val name = + nameInput.text + .toString() + .trim() + + val address = + addressInput.text + .toString() + .trim() + + if (name.isBlank()) { + Toast.makeText( + this, + "Enter a contact name", + Toast.LENGTH_LONG + ).show() + + return@setOnClickListener + } + + if (address.isBlank()) { + Toast.makeText( + this, + "Enter a Vexta address", + Toast.LENGTH_LONG + ).show() + + return@setOnClickListener + } + + if ( + !address.startsWith("vtx1") && + !address.startsWith("V") + ) { + Toast.makeText( + this, + "Enter a valid Vexta address", + Toast.LENGTH_LONG + ).show() + + return@setOnClickListener + } + + val contacts = + loadContacts().toMutableList() + + val duplicate = + contacts.any { contact -> + contact.id != existing?.id && + contact.address.equals( + address, + ignoreCase = true + ) + } + + if (duplicate) { + Toast.makeText( + this, + "This address is already in Contacts", + Toast.LENGTH_LONG + ).show() + + return@setOnClickListener + } + + val savedContact = + WalletContact( + id = + existing?.id + ?: java.util.UUID + .randomUUID() + .toString(), + name = name, + address = address + ) + + if (existing == null) { + contacts.add(savedContact) + } else { + val index = + contacts.indexOfFirst { + it.id == existing.id + } + + if (index >= 0) { + contacts[index] = + savedContact + } else { + contacts.add( + savedContact + ) + } + } + + saveContacts(contacts) + + Toast.makeText( + this, + if (existing == null) { + "Contact added" + } else { + "Contact updated" + }, + Toast.LENGTH_SHORT + ).show() + + dialog.dismiss() + + showContactsDialog( + recipientInput + ) + } + } + + dialog.show() + } + + private fun deleteContact( + recipientInput: EditText, + contact: WalletContact + ) { + AlertDialog.Builder(this) + .setTitle("Delete contact") + .setMessage( + "Delete ${contact.name} from Contacts?" + ) + .setNegativeButton("Cancel", null) + .setPositiveButton("Delete") { _, _ -> + val contacts = + loadContacts() + .filterNot { + it.id == contact.id + } + + saveContacts(contacts) + + Toast.makeText( + this, + "Contact deleted", + Toast.LENGTH_SHORT + ).show() + + showContactsDialog( + recipientInput + ) + } + .show() + } + + private fun showContactActions( + recipientInput: EditText, + contact: WalletContact + ) { + val content = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding( + dp(20), + dp(10), + dp(20), + dp(6) + ) + } + + content.addView( + TextView(this).apply { + text = contact.address + gravity = Gravity.CENTER + setTextColor(accentColor) + + maxLines = 1 + isSingleLine = true + + setAutoSizeTextTypeUniformWithConfiguration( + 8, + 15, + 1, + android.util.TypedValue.COMPLEX_UNIT_SP + ) + + paintFlags = + paintFlags or + android.graphics.Paint.UNDERLINE_TEXT_FLAG + + isClickable = true + isFocusable = true + + setPadding( + dp(4), + dp(14), + dp(4), + dp(14) + ) + + setOnClickListener { + recipientInput.setText( + contact.address + ) + + Toast.makeText( + this@WalletActivity, + "${contact.name} selected", + Toast.LENGTH_SHORT + ).show() + } + } + ) + + content.addView(space(22)) + + content.addView( + primaryButton("Edit contact") { + showContactEditor( + recipientInput, + contact + ) + } + ) + + content.addView(space(12)) + + content.addView( + primaryButton("Delete contact") { + deleteContact( + recipientInput, + contact + ) + } + ) + + AlertDialog.Builder(this) + .setTitle(contact.name) + .setView(content) + .setNegativeButton("Close", null) + .show() + } + + private fun showContactsDialog( + recipientInput: EditText + ) { + val contacts = loadContacts() + + val content = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding( + dp(20), + dp(8), + dp(20), + dp(4) + ) + } + + lateinit var contactsDialog: AlertDialog + + if (contacts.isEmpty()) { + content.addView( + smallStatus( + "No contacts saved yet." + ) + ) + } else { + contacts.forEachIndexed { + index, + contact -> + + content.addView( + secondaryButton( + contact.name + "\n" + + contact.address + ) { + contactsDialog.dismiss() + + showContactActions( + recipientInput, + contact + ) + } + ) + + if (index < contacts.lastIndex) { + content.addView(space(10)) + } + } + } + + content.addView(space(16)) + + content.addView( + primaryButton("Add contact") { + contactsDialog.dismiss() + + showContactEditor( + recipientInput + ) + } + ) + + val scroll = + ScrollView(this).apply { + addView(content) + } + + contactsDialog = + AlertDialog.Builder(this) + .setTitle("Contacts") + .setView(scroll) + .setNegativeButton("Close", null) + .create() + + contactsDialog.show() + } + private fun showSend(fromAddress: String) { mainWalletVisible = false window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) @@ -2033,6 +3249,16 @@ class WalletActivity : FragmentActivity() { } }) + addView(space(10)) + + addView( + secondaryButton("Contacts") { + showContactsDialog( + recipientInput + ) + } + ) + addView(space(12)) addView(amountInput) addView(space(10)) @@ -2270,6 +3496,21 @@ class WalletActivity : FragmentActivity() { ) ) + saveOutgoingTransactionRecord( + OutgoingTransactionRecord( + txid = transaction.txid, + amountSatoshis = + transaction.amount, + feeSatoshis = + transaction.fee, + recipientAddress = + recipient, + sentTime = + System.currentTimeMillis() / + 1000L + ) + ) + val accepted = waitForTransactionAcceptance( transaction.txid diff --git a/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt b/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt index dc358e9..5f447e8 100644 --- a/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt +++ b/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt @@ -41,6 +41,15 @@ class WalletMonitoringService : Service() { "known_incoming_txids" private const val PREF_TX_NOTIFICATIONS_INITIALIZED = "tx_notifications_initialized" + private const val PREF_LAST_BACKGROUND_SCAN_HEIGHT = + "last_background_scan_height" + private const val PREF_BACKGROUND_UTXO_PREFIX = + "background_utxos_" + private const val PREF_BACKGROUND_TX_HISTORY = + "background_transaction_history" + private const val PREF_BACKGROUND_TX_HISTORY_VERSION = + "background_transaction_history_version" + private const val BACKGROUND_TX_HISTORY_VERSION = 1 } private val handler = Handler(Looper.getMainLooper()) @@ -103,14 +112,64 @@ class WalletMonitoringService : Service() { .trim() .split(Regex("\\s+")) - val highestAddressIndex = + val preferences = getSharedPreferences(PREFS, MODE_PRIVATE) + + val highestAddressIndex = + preferences .getInt(PREF_RECEIVE_ADDRESS_INDEX, 0) .coerceAtLeast(0) + val localHeight = chain.last().height + + val previousHeight = + preferences + .getInt( + PREF_LAST_BACKGROUND_SCAN_HEIGHT, + 0 + ) + .coerceAtLeast(0) + + val backgroundCacheAvailable = + (0..highestAddressIndex).all { addressIndex -> + preferences.contains( + PREF_BACKGROUND_UTXO_PREFIX + + addressIndex + ) + } + + val transactionHistoryCurrent = + preferences.getInt( + PREF_BACKGROUND_TX_HISTORY_VERSION, + 0 + ) == BACKGROUND_TX_HISTORY_VERSION + + val fullScanRequired = + previousHeight <= 0 || + previousHeight > localHeight || + !backgroundCacheAvailable || + !transactionHistoryCurrent + + if ( + !fullScanRequired && + previousHeight == localHeight + ) { + return@Thread + } + + val startHeight = + if (fullScanRequired) { + 1 + } else { + previousHeight + 1 + } + val allTransactions = mutableListOf() + val updatedUtxos = + linkedMapOf>() + for (addressIndex in 0..highestAddressIndex) { val script = deriveWitnessScript(words, addressIndex) @@ -119,33 +178,52 @@ class WalletMonitoringService : Service() { CompactFilterClient.scan( this, "87.106.99.23", - script + script, + startHeight = startHeight ) { _, _ -> } + val initialUtxos = + if (fullScanRequired) { + emptyList() + } else { + loadBackgroundUtxos( + preferences, + addressIndex + ) + } + val blocks = BlockScanner.scan( this, "87.106.99.23", filters.matchingHeights, script, - addressIndex + addressIndex, + initialUtxos = initialUtxos ) { _, _ -> } allTransactions.addAll(blocks.transactions) + + updatedUtxos[addressIndex] = + blocks.utxos } val mergedTransactions = allTransactions .groupBy { it.txid } .map { (_, transactions) -> - BlockScanner.WalletTransaction( - txid = transactions.first().txid, - height = transactions.maxOf { + val newest = + transactions.maxByOrNull { it.height - }, + } ?: transactions.first() + + BlockScanner.WalletTransaction( + txid = newest.txid, + height = newest.height, netSatoshis = transactions.sumOf { it.netSatoshis - } + }, + blockTime = newest.blockTime ) } .filter { it.netSatoshis != 0L } @@ -153,6 +231,59 @@ class WalletMonitoringService : Service() { updateIncomingTransactionsAndNotify( mergedTransactions ) + + val cachedTransactions = + if (fullScanRequired) { + mergedTransactions + } else { + ( + loadBackgroundTransactions(preferences) + + mergedTransactions + ) + .groupBy { it.txid } + .map { (_, transactions) -> + transactions.maxByOrNull { + it.height + } ?: transactions.first() + } + } + .sortedByDescending { + it.blockTime + } + + val editor = preferences.edit() + + editor.putStringSet( + PREF_BACKGROUND_TX_HISTORY, + encodeBackgroundTransactions( + cachedTransactions + ) + ) + + for ( + addressIndex in 0..highestAddressIndex + ) { + editor.putStringSet( + PREF_BACKGROUND_UTXO_PREFIX + + addressIndex, + encodeBackgroundUtxos( + updatedUtxos[addressIndex] + ?: emptyList() + ) + ) + } + + editor.putInt( + PREF_LAST_BACKGROUND_SCAN_HEIGHT, + localHeight + ) + + editor.putInt( + PREF_BACKGROUND_TX_HISTORY_VERSION, + BACKGROUND_TX_HISTORY_VERSION + ) + + editor.apply() } catch (_: Exception) { } finally { scanRunning = false @@ -160,6 +291,47 @@ class WalletMonitoringService : Service() { }.start() } + private fun encodeBackgroundTransactions( + transactions: List + ): Set = + transactions.map { transaction -> + listOf( + transaction.txid, + transaction.height.toString(), + transaction.netSatoshis.toString(), + transaction.blockTime.toString() + ).joinToString("|") + }.toSet() + + private fun loadBackgroundTransactions( + preferences: android.content.SharedPreferences + ): List { + val values = + preferences.getStringSet( + PREF_BACKGROUND_TX_HISTORY, + emptySet() + ) ?: emptySet() + + return values.mapNotNull { value -> + try { + val parts = value.split("|") + + if (parts.size != 4) { + return@mapNotNull null + } + + BlockScanner.WalletTransaction( + txid = parts[0], + height = parts[1].toInt(), + netSatoshis = parts[2].toLong(), + blockTime = parts[3].toLong() + ) + } catch (_: Exception) { + null + } + } + } + private fun updateIncomingTransactionsAndNotify( transactions: List ) { @@ -279,6 +451,50 @@ class WalletMonitoringService : Service() { ) } + private fun encodeBackgroundUtxos( + utxos: List + ): Set = + utxos.map { utxo -> + listOf( + utxo.txid, + utxo.outputIndex.toString(), + utxo.value.toString(), + utxo.height.toString(), + utxo.addressIndex.toString() + ).joinToString("|") + }.toSet() + + private fun loadBackgroundUtxos( + preferences: android.content.SharedPreferences, + addressIndex: Int + ): List { + val values = + preferences.getStringSet( + PREF_BACKGROUND_UTXO_PREFIX + addressIndex, + emptySet() + ) ?: emptySet() + + return values.mapNotNull { value -> + try { + val parts = value.split("|") + + if (parts.size != 5) { + return@mapNotNull null + } + + BlockScanner.SpendableUtxo( + txid = parts[0], + outputIndex = parts[1].toLong(), + value = parts[2].toLong(), + height = parts[3].toInt(), + addressIndex = parts[4].toInt() + ) + } catch (_: Exception) { + null + } + } + } + private fun walletExists(): Boolean { val prefs = getSharedPreferences(PREFS, MODE_PRIVATE)