Iterate on the Send demo.

Allow input. Allow multiple sends. Handle errors. Demonstrate an improved user experience where sending is disabled at the appropriate times.
This commit is contained in:
Kevin Gorham 2019-11-23 18:07:28 -05:00
parent 93d4114848
commit 652e862d5c
No known key found for this signature in database
GPG Key ID: CCA55602DF49FC38
5 changed files with 174 additions and 52 deletions

View File

@ -2,19 +2,18 @@ package cash.z.wallet.sdk.demoapp.demos.send
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.Toast import android.widget.TextView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import cash.z.wallet.sdk.Initializer import cash.z.wallet.sdk.Initializer
import cash.z.wallet.sdk.Synchronizer import cash.z.wallet.sdk.Synchronizer
import cash.z.wallet.sdk.block.CompactBlockProcessor import cash.z.wallet.sdk.block.CompactBlockProcessor
import cash.z.wallet.sdk.demoapp.App import cash.z.wallet.sdk.demoapp.App
import cash.z.wallet.sdk.demoapp.BaseDemoFragment import cash.z.wallet.sdk.demoapp.BaseDemoFragment
import cash.z.wallet.sdk.demoapp.R
import cash.z.wallet.sdk.demoapp.databinding.FragmentSendBinding import cash.z.wallet.sdk.demoapp.databinding.FragmentSendBinding
import cash.z.wallet.sdk.demoapp.util.SampleStorageBridge import cash.z.wallet.sdk.demoapp.util.SampleStorageBridge
import cash.z.wallet.sdk.entity.* import cash.z.wallet.sdk.entity.*
import cash.z.wallet.sdk.ext.* import cash.z.wallet.sdk.ext.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class SendFragment : BaseDemoFragment<FragmentSendBinding>() { class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
private val config = App.instance.defaultConfig private val config = App.instance.defaultConfig
@ -23,6 +22,36 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
private lateinit var synchronizer: Synchronizer private lateinit var synchronizer: Synchronizer
private lateinit var keyManager: SampleStorageBridge private lateinit var keyManager: SampleStorageBridge
private lateinit var amountInput: TextView
private lateinit var addressInput: TextView
//
// Observable properties (done without livedata or flows for simplicity)
//
private var availableBalance = -1L
set(value) {
field = value
onUpdateSendButton()
}
private var isSending = false
set(value) {
field = value
if (value) Twig.sprout("Sending") else Twig.clip("Sending")
onUpdateSendButton()
}
private var isSyncing = true
set(value) {
field = value
onUpdateSendButton()
}
//
// BaseDemoFragment overrides
//
override fun inflateBinding(layoutInflater: LayoutInflater): FragmentSendBinding = override fun inflateBinding(layoutInflater: LayoutInflater): FragmentSendBinding =
FragmentSendBinding.inflate(layoutInflater) FragmentSendBinding.inflate(layoutInflater)
@ -32,13 +61,30 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
synchronizer = Synchronizer(App.instance, config.host, initializer.rustBackend) synchronizer = Synchronizer(App.instance, config.host, initializer.rustBackend)
} }
// STARTING POINT
override fun onResetComplete() { override fun onResetComplete() {
initSendUI() initSendUi()
startSynchronizer() startSynchronizer()
monitorStatus() monitorChanges()
} }
private fun initSendUI() { override fun onClear() {
synchronizer.stop()
initializer.clear()
}
//
// Private functions
//
private fun initSendUi() {
amountInput = binding.root.findViewById<TextView>(R.id.input_amount).apply {
text = config.sendAmount.toString()
}
addressInput = binding.root.findViewById<TextView>(R.id.input_address).apply {
text = config.toAddress
}
binding.buttonSend.setOnClickListener(::onSend) binding.buttonSend.setOnClickListener(::onSend)
} }
@ -48,7 +94,7 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
} }
} }
private fun monitorStatus() { private fun monitorChanges() {
synchronizer.status.collectWith(lifecycleScope, ::onStatus) synchronizer.status.collectWith(lifecycleScope, ::onStatus)
synchronizer.progress.collectWith(lifecycleScope, ::onProgress) synchronizer.progress.collectWith(lifecycleScope, ::onProgress)
synchronizer.balances.collectWith(lifecycleScope, ::onBalance) synchronizer.balances.collectWith(lifecycleScope, ::onBalance)
@ -56,11 +102,12 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
private fun onStatus(status: Synchronizer.Status) { private fun onStatus(status: Synchronizer.Status) {
binding.textStatus.text = "Status: $status" binding.textStatus.text = "Status: $status"
} if (status == Synchronizer.Status.SYNCING) {
isSyncing = true
override fun onClear() { binding.textBalance.text = "Calculating balance..."
synchronizer.stop() } else {
initializer.clear() isSyncing = false
}
} }
private fun onProgress(i: Int) { private fun onProgress(i: Int) {
@ -69,39 +116,69 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
else -> "Downloading blocks...$i%" else -> "Downloading blocks...$i%"
} }
binding.textStatus.text = message binding.textStatus.text = message
binding.textBalance.text = ""
} }
private fun onBalance(balance: CompactBlockProcessor.WalletBalance) { private fun onBalance(balance: CompactBlockProcessor.WalletBalance) {
binding.textBalances.text = """ availableBalance = balance.available
binding.textBalance.text = """
Available balance: ${balance.available.convertZatoshiToZecString()} Available balance: ${balance.available.convertZatoshiToZecString()}
Total balance: ${balance.total.convertZatoshiToZecString()} Total balance: ${balance.total.convertZatoshiToZecString()}
""".trimIndent() """.trimIndent()
binding.buttonSend.isEnabled = balance.available > 0
binding.textStatus.text = "Synced!"
} }
private fun onSend(unused: View) { private fun onSend(unused: View) {
// TODO: add input fields to the UI. Possibly, including a scanner for the address input isSending = true
val amount = amountInput.text.toString().toDouble().convertZecToZatoshi()
val toAddress = addressInput.text.toString()
synchronizer.sendToAddress( synchronizer.sendToAddress(
keyManager.key, keyManager.key,
0.0024.toZec().convertZecToZatoshi(), amount,
config.toAddress, toAddress,
"Demo App Funds" "Demo App Funds"
).collectWith(lifecycleScope, ::onPendingTxUpdated) ).collectWith(lifecycleScope, ::onPendingTxUpdated)
} }
private fun onPendingTxUpdated(pendingTransaction: PendingTransaction?) { private fun onPendingTxUpdated(pendingTransaction: PendingTransaction?) {
val id = pendingTransaction?.id ?: -1
val message = when { val message = when {
pendingTransaction == null -> "Transaction not found" pendingTransaction == null -> "Transaction not found"
pendingTransaction.isMined() -> "Transaction Mined!" pendingTransaction.isMined() -> "Transaction Mined (id: $id)!\n\nSEND COMPLETE".also { isSending = false }
pendingTransaction.isSubmitted() -> "Successfully submitted transaction!" pendingTransaction.isSubmitSuccess() -> "Successfully submitted transaction!\nAwaiting confirmation..."
pendingTransaction.isFailedEncoding() -> "ERROR: failed to encode transaction!" pendingTransaction.isFailedEncoding() -> "ERROR: failed to encode transaction! (id: $id)".also { isSending = false }
pendingTransaction.isFailedSubmit() -> "ERROR: failed to submit transaction!" pendingTransaction.isFailedSubmit() -> "ERROR: failed to submit transaction! (id: $id)".also { isSending = false }
pendingTransaction.isCreated() -> "Transaction creation complete!" pendingTransaction.isCreated() -> "Transaction creation complete! (id: $id)"
pendingTransaction.isCreating() -> "Creating transaction!" pendingTransaction.isCreating() -> "Creating transaction!".also { onResetInfo() }
else -> "Transaction updated!".also { twig("Unhandled TX state: $pendingTransaction") } else -> "Transaction updated!".also { twig("Unhandled TX state: $pendingTransaction") }
} }
twig("PENDING TX: $message") twig("Pending TX Updated: $message")
Toast.makeText(App.instance, message, Toast.LENGTH_SHORT).show() binding.textInfo.apply {
text = "$text\n$message"
}
} }
private fun onUpdateSendButton() {
with(binding.buttonSend) {
when {
isSending -> {
text = "➡ sending"
isEnabled = false
}
isSyncing -> {
text = "⌛ syncing"
isEnabled = false
}
availableBalance <= 0 -> isEnabled = false
else -> {
text = "send"
isEnabled = true
}
}
}
}
private fun onResetInfo() {
binding.textInfo.text = "Active Transaction:"
}
} }

View File

@ -2,7 +2,6 @@
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
@ -15,14 +14,32 @@
app:layout_constraintHorizontal_chainStyle="packed" app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.2"> app:layout_constraintVertical_bias="0.05">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/text_block_height" android:id="@+id/input_amount"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ems="8" android:ems="8"
android:hint="amount" android:hint="zec amount"
android:inputType="number"
android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/text_layout_address"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="@id/text_layout_amount"
app:layout_constraintTop_toBottomOf="@id/text_layout_amount">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_address"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ems="8"
android:hint="to address"
android:inputType="number" android:inputType="number"
android:textSize="20sp" /> android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
@ -46,14 +63,30 @@
android:layout_marginTop="32dp" android:layout_marginTop="32dp"
android:text="Initializing wallet..." android:text="Initializing wallet..."
app:layout_constraintStart_toStartOf="@id/text_layout_amount" app:layout_constraintStart_toStartOf="@id/text_layout_amount"
app:layout_constraintTop_toBottomOf="@id/text_layout_amount" /> app:layout_constraintTop_toBottomOf="@id/text_layout_address" />
<TextView <TextView
android:id="@+id/text_balances" android:id="@+id/text_balance"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
app:layout_constraintStart_toStartOf="@id/text_status" app:layout_constraintStart_toStartOf="@id/text_status"
app:layout_constraintTop_toBottomOf="@id/text_status" app:layout_constraintTop_toBottomOf="@id/text_status" />
android:text="Available balance: --\nTotal balance: --" />
<ScrollView
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintStart_toStartOf="@id/text_balance"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_balance"
app:layout_constraintBottom_toBottomOf="parent"
>
<TextView
android:id="@+id/text_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:paddingEnd="8dp"
android:paddingBottom="48dp"/>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,7 +1,6 @@
package cash.z.wallet.sdk package cash.z.wallet.sdk
import android.content.Context import android.content.Context
import androidx.paging.PagedList
import cash.z.wallet.sdk.Synchronizer.Status.* import cash.z.wallet.sdk.Synchronizer.Status.*
import cash.z.wallet.sdk.block.CompactBlockDbStore import cash.z.wallet.sdk.block.CompactBlockDbStore
import cash.z.wallet.sdk.block.CompactBlockDownloader import cash.z.wallet.sdk.block.CompactBlockDownloader
@ -254,16 +253,19 @@ class SdkSynchronizer internal constructor(
// TODO: this would be the place to clear out any stale pending transactions. Remove filter // TODO: this would be the place to clear out any stale pending transactions. Remove filter
// logic and then delete any pending transaction with sufficient confirmations (all in one // logic and then delete any pending transaction with sufficient confirmations (all in one
// db transaction). // db transaction).
manager.getAll().first().filter { !it.isMined() }.forEach { pendingTx -> manager.getAll().first().filter { it.isSubmitSuccess() && !it.isMined() }
twig("checking for updates on pendingTx id: ${pendingTx.id}") .forEach { pendingTx ->
pendingTx.rawTransactionId?.let { rawId -> twig("checking for updates on pendingTx id: ${pendingTx.id}")
ledger.findMinedHeight(rawId)?.let { minedHeight -> pendingTx.rawTransactionId?.let { rawId ->
twig("found matching transaction for pending transaction with id" + ledger.findMinedHeight(rawId)?.let { minedHeight ->
" ${pendingTx.id} mined at height ${minedHeight}!") twig(
manager.applyMinedHeight(pendingTx, minedHeight) "found matching transaction for pending transaction with id" +
" ${pendingTx.id} mined at height ${minedHeight}!"
)
manager.applyMinedHeight(pendingTx, minedHeight)
}
} }
} }
}
} }
@ -287,13 +289,15 @@ class SdkSynchronizer internal constructor(
manager.initSpend(zatoshi, toAddress, memo, fromAccountIndex).let { placeHolderTx -> manager.initSpend(zatoshi, toAddress, memo, fromAccountIndex).let { placeHolderTx ->
emit(placeHolderTx) emit(placeHolderTx)
manager.encode(spendingKey, placeHolderTx).let { encodedTx -> manager.encode(spendingKey, placeHolderTx).let { encodedTx ->
manager.submit(encodedTx) if (!encodedTx.isFailedEncoding() && !encodedTx.isCancelled()) {
manager.submit(encodedTx)
}
} }
} }
}.flatMapLatest { }.flatMapLatest {
twig("Monitoring pending transaction for updates...") twig("Monitoring pending transaction (id: ${it.id}) for updates...")
manager.monitorById(it.id) manager.monitorById(it.id)
} }.distinctUntilChanged()
} }

View File

@ -141,6 +141,15 @@ inline fun Double?.toZec(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): B
return BigDecimal(this?.toString() ?: "0.0", MathContext.DECIMAL128).setScale(decimals, ZEC_FORMATTER.roundingMode) return BigDecimal(this?.toString() ?: "0.0", MathContext.DECIMAL128).setScale(decimals, ZEC_FORMATTER.roundingMode)
} }
/**
* Format a Double Zec value as a Long Zatoshi value, by first converting to Zec with the given
* precision.
* Start with Zec -> End with Zatoshi.
*/
inline fun Double?.convertZecToZatoshi(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): Long {
return this.toZec(decimals).convertZecToZatoshi()
}
/** /**
* Format a BigDecimal Zec value as a BigDecimal Zec value, right-padded to the given number of fraction digits. * Format a BigDecimal Zec value as a BigDecimal Zec value, right-padded to the given number of fraction digits.
* Start with Zec -> End with Zec. * Start with Zec -> End with Zec.

View File

@ -1,8 +1,6 @@
package cash.z.wallet.sdk.transaction package cash.z.wallet.sdk.transaction
import android.content.Context import android.content.Context
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import cash.z.wallet.sdk.db.PendingTransactionDao import cash.z.wallet.sdk.db.PendingTransactionDao
@ -15,6 +13,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.lang.IllegalStateException
import kotlin.math.max import kotlin.math.max
/** /**
@ -134,14 +133,14 @@ class PersistentTransactionManager(
} }
override suspend fun submit(pendingTx: PendingTransaction): PendingTransaction = withContext(Dispatchers.IO) { override suspend fun submit(pendingTx: PendingTransaction): PendingTransaction = withContext(Dispatchers.IO) {
var tx1 = pendingTransactionDao { findById(pendingTx.id) } // reload the tx to check for cancellation
if(tx1 == null) twig("unable to find transaction for id: ${pendingTx.id}") var storedTx = pendingTransactionDao { findById(pendingTx.id) } ?: throw IllegalStateException("Error while submitting transaction. No pending transaction found that matches the one being submitted. Verify that the transaction still exists among the set of pending transactions.")
var tx = tx1!! var tx = storedTx
try { try {
// do nothing when cancelled // do nothing when cancelled
if (!tx.isCancelled()) { if (!tx.isCancelled()) {
twig("submitting transaction to lightwalletd - memo: ${tx.memo} amount: ${tx.value}") twig("submitting transaction to lightwalletd - memo: ${tx.memo} amount: ${tx.value}")
val response = service.submitTransaction(tx.raw!!) val response = service.submitTransaction(tx.raw)
val error = response.errorCode < 0 val error = response.errorCode < 0
twig("${if (error) "FAILURE! " else "SUCCESS!"} submit transaction completed with response: ${response.errorCode}: ${response.errorMessage}") twig("${if (error) "FAILURE! " else "SUCCESS!"} submit transaction completed with response: ${response.errorCode}: ${response.errorMessage}")
tx = tx.copy( tx = tx.copy(