diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index efcc0a9..e206e75 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 = 34
- versionName = "0.6.4"
+ versionCode = 35
+ versionName = "0.6.5"
}
signingConfigs {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f056927..c75b2a1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,6 +7,7 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/org/vextaproject/wallet/BootReceiver.kt b/app/src/main/java/org/vextaproject/wallet/BootReceiver.kt
new file mode 100644
index 0000000..5cb07a9
--- /dev/null
+++ b/app/src/main/java/org/vextaproject/wallet/BootReceiver.kt
@@ -0,0 +1,37 @@
+package org.vextaproject.wallet
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+
+class BootReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent?) {
+ if (
+ intent?.action != Intent.ACTION_BOOT_COMPLETED &&
+ intent?.action != Intent.ACTION_MY_PACKAGE_REPLACED
+ ) {
+ return
+ }
+
+ val walletExists =
+ context.getSharedPreferences(
+ "vexta_wallet",
+ Context.MODE_PRIVATE
+ ).contains("seed_ciphertext")
+
+ if (!walletExists) {
+ return
+ }
+
+ val monitoringIntent =
+ Intent(context, WalletMonitoringService::class.java)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(monitoringIntent)
+ } else {
+ context.startService(monitoringIntent)
+ }
+ }
+}
diff --git a/app/src/main/java/org/vextaproject/wallet/VextaTransactionSender.kt b/app/src/main/java/org/vextaproject/wallet/VextaTransactionSender.kt
index e5aff19..542cb7e 100644
--- a/app/src/main/java/org/vextaproject/wallet/VextaTransactionSender.kt
+++ b/app/src/main/java/org/vextaproject/wallet/VextaTransactionSender.kt
@@ -43,6 +43,39 @@ object VextaTransactionSender {
val spentOutpoints: Set
)
+ data class MaxSpend(
+ val amount: Long,
+ val fee: Long
+ )
+
+ fun calculateMaxSpend(
+ spendableUtxos: List
+ ): MaxSpend {
+ require(spendableUtxos.isNotEmpty()) {
+ "Wallet has no spendable outputs"
+ }
+
+ val total = spendableUtxos.fold(0L) { sum, utxo ->
+ Math.addExact(sum, utxo.value)
+ }
+
+ val fee = estimateFee(
+ inputCount = spendableUtxos.size,
+ outputCount = 1
+ )
+
+ val amount = total - fee
+
+ require(amount > 0L) {
+ "Balance is too small to cover the transaction fee"
+ }
+
+ return MaxSpend(
+ amount = amount,
+ fee = fee
+ )
+ }
+
fun createAndSign(
spendableUtxos: List,
recipientAddress: String,
@@ -72,49 +105,66 @@ object VextaTransactionSender {
.thenBy { it.outputIndex }
)
+ val maxSpend = calculateMaxSpend(orderedUtxos)
+ val sendingMaximum =
+ amountSatoshis == maxSpend.amount
+
val selected =
mutableListOf()
var selectedValue = 0L
+ var fee: Long
+ var change: Long
- for (utxo in orderedUtxos) {
- selected.add(utxo)
- selectedValue =
- Math.addExact(selectedValue, utxo.value)
+ if (sendingMaximum) {
+ selected.addAll(orderedUtxos)
- val estimatedFee = estimateFee(
+ selectedValue = selected.fold(0L) { sum, utxo ->
+ Math.addExact(sum, utxo.value)
+ }
+
+ fee = maxSpend.fee
+ change = 0L
+ } else {
+ for (utxo in orderedUtxos) {
+ selected.add(utxo)
+ selectedValue =
+ Math.addExact(selectedValue, utxo.value)
+
+ val estimatedFee = estimateFee(
+ inputCount = selected.size,
+ outputCount = 2
+ )
+
+ if (
+ selectedValue >=
+ amountSatoshis + estimatedFee
+ ) {
+ break
+ }
+ }
+
+ if (selected.isEmpty()) {
+ throw IllegalStateException(
+ "Wallet has no spendable outputs"
+ )
+ }
+
+ fee = estimateFee(
inputCount = selected.size,
outputCount = 2
)
- if (
- selectedValue >=
- amountSatoshis + estimatedFee
- ) {
- break
+ if (selectedValue < amountSatoshis + fee) {
+ throw IllegalStateException(
+ "Insufficient balance including transaction fee"
+ )
}
+
+ change =
+ selectedValue - amountSatoshis - fee
}
- if (selected.isEmpty()) {
- throw IllegalStateException(
- "Wallet has no spendable outputs"
- )
- }
-
- var fee = estimateFee(
- inputCount = selected.size,
- outputCount = 2
- )
-
- if (selectedValue < amountSatoshis + fee) {
- throw IllegalStateException(
- "Insufficient balance including transaction fee"
- )
- }
-
- var change =
- selectedValue - amountSatoshis - fee
-
val outputs = mutableListOf(
TransactionOutputData(
value = amountSatoshis,
@@ -122,16 +172,18 @@ object VextaTransactionSender {
)
)
- if (change >= dustLimit) {
- outputs.add(
- TransactionOutputData(
- value = change,
- script = changeScript
+ if (!sendingMaximum) {
+ if (change >= dustLimit) {
+ outputs.add(
+ TransactionOutputData(
+ value = change,
+ script = changeScript
+ )
)
- )
- } else {
- fee += change
- change = 0L
+ } else {
+ fee += change
+ change = 0L
+ }
}
/*
diff --git a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt
index fa03e49..35299f1 100644
--- a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt
+++ b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt
@@ -267,12 +267,6 @@ class WalletActivity : FragmentActivity() {
val now = System.currentTimeMillis()
if (now - lastExitSwipeAt <= 2_000L) {
- stopService(
- Intent(
- this@WalletActivity,
- WalletMonitoringService::class.java
- )
- )
finishAndRemoveTask()
} else {
lastExitSwipeAt = now
@@ -372,12 +366,6 @@ class WalletActivity : FragmentActivity() {
val now = System.currentTimeMillis()
if (now - lastExitSwipeAt <= 2_000L) {
- stopService(
- Intent(
- this,
- WalletMonitoringService::class.java
- )
- )
finishAndRemoveTask()
} else {
lastExitSwipeAt = now
@@ -1404,10 +1392,10 @@ class WalletActivity : FragmentActivity() {
addView(sectionTitle("Get started"))
addView(space(10))
addView(primaryButton("Create new wallet") {
- createNewWallet()
+ showSeedLengthDialog()
})
addView(space(12))
- addView(secondaryButton("Restore from 12 words") {
+ addView(secondaryButton("Restore wallet") {
showRestoreDialog()
})
}
@@ -1416,11 +1404,59 @@ class WalletActivity : FragmentActivity() {
setContentView(scroll(content))
}
- private fun createNewWallet() {
- val entropy = ByteArray(16)
+ private fun showSeedLengthDialog() {
+ val content = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(
+ dp(20),
+ dp(8),
+ dp(20),
+ dp(4)
+ )
+
+ addView(
+ primaryButton("12-word recovery phrase") {
+ createNewWallet(12)
+ }
+ )
+
+ addView(space(12))
+
+ addView(
+ primaryButton("24-word recovery phrase") {
+ createNewWallet(24)
+ }
+ )
+ }
+
+ AlertDialog.Builder(this)
+ .setTitle("Recovery phrase length")
+ .setMessage(
+ "Choose the recovery phrase length for your new wallet."
+ )
+ .setView(content)
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+
+ private fun createNewWallet(wordCount: Int) {
+ val entropyBytes = when (wordCount) {
+ 12 -> 16
+ 24 -> 32
+ else -> throw IllegalArgumentException(
+ "Unsupported recovery phrase length"
+ )
+ }
+
+ val entropy = ByteArray(entropyBytes)
SecureRandom().nextBytes(entropy)
val words = MnemonicCode.INSTANCE.toMnemonic(entropy)
+
+ require(words.size == wordCount) {
+ "Unexpected recovery phrase length"
+ }
+
saveMnemonic(words.joinToString(" "))
getSharedPreferences(PREFS, MODE_PRIVATE)
@@ -1441,7 +1477,7 @@ class WalletActivity : FragmentActivity() {
content.addView(title("Recovery phrase"))
content.addView(
subtitle(
- "Write these 12 words down in the correct order.\n" +
+ "Write these ${words.size} words down in the correct order.\n" +
"Anyone with these words can control your VTX."
)
)
@@ -1449,7 +1485,7 @@ class WalletActivity : FragmentActivity() {
content.addView(
card {
- addView(sectionTitle("Your 12 words"))
+ addView(sectionTitle("Your ${words.size} words"))
addView(space(14))
addView(
@@ -1674,12 +1710,6 @@ class WalletActivity : FragmentActivity() {
val now = System.currentTimeMillis()
if (now - lastExitSwipeAt <= 2_000L) {
- stopService(
- Intent(
- this,
- WalletMonitoringService::class.java
- )
- )
finishAndRemoveTask()
} else {
lastExitSwipeAt = now
@@ -2005,6 +2035,80 @@ class WalletActivity : FragmentActivity() {
addView(space(12))
addView(amountInput)
+ addView(space(10))
+
+ val availableSatoshis =
+ latestSpendableUtxos.sumOf { it.value }
+
+ addView(
+ smallStatus(
+ "Available: " +
+ String.format(
+ java.util.Locale.US,
+ "%.8f VTX",
+ availableSatoshis.toDouble() /
+ 100_000_000.0
+ ) +
+ "\nNetwork fee is shown before sending."
+ )
+ )
+
+ addView(space(10))
+
+ addView(
+ secondaryButton("Send Max") {
+ try {
+ val maxSpend =
+ VextaTransactionSender.calculateMaxSpend(
+ latestSpendableUtxos
+ )
+
+ amountInput.setText(
+ String.format(
+ java.util.Locale.US,
+ "%.8f",
+ maxSpend.amount.toDouble() /
+ 100_000_000.0
+ )
+ )
+
+ noteView.text =
+ "Send Max selected\n" +
+ "Available: " +
+ String.format(
+ java.util.Locale.US,
+ "%.8f VTX",
+ (
+ maxSpend.amount +
+ maxSpend.fee
+ ).toDouble() /
+ 100_000_000.0
+ ) +
+ "\nNetwork fee: " +
+ String.format(
+ java.util.Locale.US,
+ "%.8f VTX",
+ maxSpend.fee.toDouble() /
+ 100_000_000.0
+ ) +
+ "\nSending: " +
+ String.format(
+ java.util.Locale.US,
+ "%.8f VTX",
+ maxSpend.amount.toDouble() /
+ 100_000_000.0
+ )
+ } catch (error: Exception) {
+ Toast.makeText(
+ this@WalletActivity,
+ error.message
+ ?: "Unable to calculate maximum amount",
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
+ )
+
addView(space(14))
addView(primaryButton("Review payment") {
if (sendInProgress) {
@@ -2355,7 +2459,7 @@ class WalletActivity : FragmentActivity() {
private fun showRestoreDialog() {
val input = EditText(this).apply {
- hint = "Enter the 12 recovery words"
+ hint = "Enter your 12 or 24 recovery words"
minLines = 5
gravity = Gravity.TOP
setTextColor(textPrimary)
@@ -2370,7 +2474,9 @@ class WalletActivity : FragmentActivity() {
AlertDialog.Builder(this)
.setTitle("Restore Vexta Wallet")
- .setMessage("Enter all 12 words separated by spaces.")
+ .setMessage(
+ "Enter all 12 or 24 recovery words separated by spaces."
+ )
.setView(input)
.setNegativeButton("Cancel", null)
.setPositiveButton("Restore") { _, _ ->
@@ -2387,8 +2493,10 @@ class WalletActivity : FragmentActivity() {
.split(Regex("\\s+"))
.filter { it.isNotBlank() }
- if (words.size != 12) {
- throw IllegalArgumentException("Exactly 12 words are required")
+ if (words.size != 12 && words.size != 24) {
+ throw IllegalArgumentException(
+ "Recovery phrase must contain 12 or 24 words"
+ )
}
MnemonicCode.INSTANCE.check(words)
@@ -2527,7 +2635,7 @@ class WalletActivity : FragmentActivity() {
.setTitle("Delete wallet?")
.setMessage(
"This removes the encrypted seed from this phone. " +
- "The wallet can only be recovered with its 12 words."
+ "The wallet can only be recovered with its recovery phrase."
)
.setNegativeButton("Cancel", null)
.setPositiveButton("Delete") { _, _ ->