From 8f9da23f27d5b15612c777614324b5985e6ecfc3 Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Wed, 29 Jul 2020 13:10:32 -0400 Subject: [PATCH 01/10] Pull in SDK changes from hackathon. --- app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt | 3 +-- build.gradle | 1 + buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt index d3461ba..602a028 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt @@ -127,8 +127,7 @@ class ScanFragment : BaseFragment() { private fun onQrScanned(qrContent: String, image: ImageProxy) { resumedScope.launch { if (viewModel.isNotValid(qrContent)) { - //todo: use the "NETWORK" constant that will be available in the next SDK build - val network = ZcashSdk.DEFAULT_DB_NAME_PREFIX.split("_")[1] + val network = ZcashSdk.NETWORK binding.textScanError.text = "Invalid Zcash $network address:\n$qrContent" image.close() } else { /* continue scanning*/ diff --git a/build.gradle b/build.gradle index 86d5621..0f40018 100644 --- a/build.gradle +++ b/build.gradle @@ -19,6 +19,7 @@ buildscript { allprojects { repositories { + mavenLocal() google() jcenter() maven { url 'https://jitpack.io' } diff --git a/buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt b/buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt index 2554f1e..8c84c9d 100644 --- a/buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt +++ b/buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt @@ -79,7 +79,7 @@ object Deps { object Zcash { const val ANDROID_WALLET_PLUGINS = "cash.z.ecc.android:zcash-android-wallet-plugins:1.0.0" const val KOTLIN_BIP39 = "cash.z.ecc.android:kotlin-bip39:1.0.0-beta09" - object Sdk : Version("1.1.0-beta02") { + object Sdk : Version("1.1.0-beta03") { val MAINNET = "cash.z.ecc.android:sdk-mainnet:$version" val TESTNET = "cash.z.ecc.android:sdk-testnet:$version" } From 6f44dbcb8b99edc1e69e83038d3981430edb60de Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Sat, 1 Aug 2020 02:45:18 -0400 Subject: [PATCH 02/10] New: improve reply-to support in memos. --- .../ui/detail/TransactionViewHolder.kt | 25 ++++++++++++------- .../cash/z/ecc/android/ui/util/MemoUtil.kt | 17 ++++++++++++- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionViewHolder.kt b/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionViewHolder.kt index 68ac046..b50a79a 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionViewHolder.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionViewHolder.kt @@ -8,7 +8,7 @@ import cash.z.ecc.android.R import cash.z.ecc.android.ext.goneIf import cash.z.ecc.android.ext.toAppColor import cash.z.ecc.android.ui.MainActivity -import cash.z.ecc.android.ui.util.INCLUDE_MEMO_PREFIX +import cash.z.ecc.android.ui.util.INCLUDE_MEMO_PREFIXES_RECOGNIZED import cash.z.ecc.android.ui.util.toUtf8Memo import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction import cash.z.ecc.android.sdk.ext.* @@ -100,21 +100,28 @@ class TransactionViewHolder(itemView: View) : Recycler private suspend fun getSender(transaction: ConfirmedTransaction): String { val memo = transaction.memo.toUtf8Memo() - val who = extractValidAddress(memo, INCLUDE_MEMO_PREFIX) - ?: extractValidAddress(memo, "sent from:") - ?: "Unknown" - + val who = extractValidAddress(memo)?.toAbbreviatedAddress() ?: "Unknown" return "$who paid you" } private fun extractAddress(memo: String?) = addressRegex.findAll(memo ?: "").lastOrNull()?.value - private suspend fun extractValidAddress(memo: String?, delimiter: String): String? { + private suspend fun extractValidAddress(memo: String?): String? { + if (memo == null || memo.length < 25) return null + // note: cannot use substringAfterLast because we need to ignore case - return memo?.lastIndexOf(delimiter, ignoreCase = true)?.let { i -> - memo.substring(i + delimiter.length).trimStart() - }?.validateAddress() + try { + INCLUDE_MEMO_PREFIXES_RECOGNIZED.forEach { prefix -> + memo.lastIndexOf(prefix, ignoreCase = true).takeUnless { it == -1 }?.let { lastIndex -> + memo.substring(lastIndex + prefix.length).trimStart().validateAddress()?.let { address -> + return@extractValidAddress address + } + } + } + } catch(t: Throwable) { } + + return null } private fun onTransactionClicked(transaction: ConfirmedTransaction) { diff --git a/app/src/main/java/cash/z/ecc/android/ui/util/MemoUtil.kt b/app/src/main/java/cash/z/ecc/android/ui/util/MemoUtil.kt index 75419ae..825b156 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/util/MemoUtil.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/util/MemoUtil.kt @@ -2,8 +2,23 @@ package cash.z.ecc.android.ui.util import java.nio.charset.StandardCharsets +/** + * The prefix that this wallet uses whenever the user chooses to include their address in the memo. + * This is the one we standardize around. + */ +const val INCLUDE_MEMO_PREFIX_STANDARD = "Reply-To:" -const val INCLUDE_MEMO_PREFIX = "Reply-To:" +/** + * The non-standard prefixes that we will parse if other wallets send them our way. + */ +val INCLUDE_MEMO_PREFIXES_RECOGNIZED = arrayOf( + INCLUDE_MEMO_PREFIX_STANDARD, // standard + "reply-to", // standard w/o colon + "reply to:", // space instead of dash + "reply to", // space instead of dash w/o colon + "sent from:", // previous standard + "sent from" // previous standard w/o colon +) inline fun ByteArray?.toUtf8Memo(): String { // TODO: make this more official but for now, this will do From 8e5c87e301ec737a14686e2d450a1fde125f4e3a Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Sat, 1 Aug 2020 02:46:49 -0400 Subject: [PATCH 03/10] New: Add biometric authentication support. --- app/build.gradle | 5 ++- .../cash/z/ecc/android/ui/MainActivity.kt | 39 +++++++++++++++++++ .../java/cash/z/ecc/android/Dependencies.kt | 1 + 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 20a4b4a..9f8bc43 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,7 +10,7 @@ apply plugin: 'com.google.firebase.crashlytics' archivesBaseName = 'zcash-android-wallet' group = 'cash.z.ecc.android' -version = '1.0.0-alpha31' +version = '1.0.0-alpha32' android { ndkVersion "21.1.6352462" @@ -21,7 +21,7 @@ android { applicationId 'cash.z.ecc.android' minSdkVersion Deps.minSdkVersion targetSdkVersion Deps.targetSdkVersion - versionCode = 1_00_00_031 + versionCode = 1_00_00_032 // last digits are alpha(0XX) beta(2XX) rc(4XX) release(8XX) dev(9XX). Ex: 1_08_04_401 is an release candidate build of version 1.8.4 and 1_08_04_800 would be the final release. versionName = "$version" testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' @@ -109,6 +109,7 @@ dependencies { // Android implementation Deps.AndroidX.ANNOTATION implementation Deps.AndroidX.APPCOMPAT + implementation Deps.AndroidX.BIOMETRICS implementation Deps.AndroidX.CONSTRAINT_LAYOUT implementation Deps.AndroidX.CORE_KTX implementation Deps.AndroidX.FRAGMENT_KTX diff --git a/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt b/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt index d58dbca..ceb57be 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt @@ -20,6 +20,8 @@ import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.annotation.IdRes import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricConstants +import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.fragment.app.Fragment @@ -187,6 +189,43 @@ class MainActivity : AppCompatActivity() { action?.let { feedback.report(it) } } + fun authenticate(description: String, block: () -> Unit) { + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + twig("Authentication success") + block() + } + + override fun onAuthenticationFailed() { + twig("Authentication failed!!!!") + } + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + twig("Authenticatiion Error") + when (errorCode) { + BiometricConstants.ERROR_HW_NOT_PRESENT, BiometricConstants.ERROR_HW_UNAVAILABLE, + BiometricConstants.ERROR_NO_BIOMETRICS, BiometricConstants.ERROR_NO_DEVICE_CREDENTIAL -> { + twig("Warning: bypassing authentication because $errString [$errorCode]") + block() + } + else -> { + twig("Warning: failed authentication because $errString [$errorCode]") + } + } + } + } + + BiometricPrompt(this, ContextCompat.getMainExecutor(this), callback).apply { + authenticate( + BiometricPrompt.PromptInfo.Builder() + .setTitle("Authenticate to Proceed") + .setConfirmationRequired(false) + .setDescription(description) + .setDeviceCredentialAllowed(true) + .build() + ) + } + } + fun playSound(fileName: String) { mediaPlayer.apply { if (isPlaying) stop() diff --git a/buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt b/buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt index 8c84c9d..961ea41 100644 --- a/buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt +++ b/buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt @@ -13,6 +13,7 @@ object Deps { object AndroidX { const val ANNOTATION = "androidx.annotation:annotation:1.1.0" const val APPCOMPAT = "androidx.appcompat:appcompat:1.1.0" + const val BIOMETRICS = "androidx.biometric:biometric:1.1.0-alpha01" const val CONSTRAINT_LAYOUT = "androidx.constraintlayout:constraintlayout:1.1.3" const val CORE_KTX = "androidx.core:core-ktx:1.1.0" const val FRAGMENT_KTX = "androidx.fragment:fragment-ktx:1.1.0-beta01" From c92cfaf07d5c45025e330dc0cde2edfd6ee7e27d Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Sat, 1 Aug 2020 02:48:20 -0400 Subject: [PATCH 04/10] New: more precise birthday height support This is important because sync times are getting longer, every day. If users can see and input a more accurate birthday, then they will have dramatically improved sync times. --- .../z/ecc/android/ui/setup/BackupFragment.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/cash/z/ecc/android/ui/setup/BackupFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/setup/BackupFragment.kt index 7990411..64aef46 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/setup/BackupFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/setup/BackupFragment.kt @@ -19,12 +19,15 @@ import cash.z.ecc.android.feedback.Report.Tap.BACKUP_DONE import cash.z.ecc.android.feedback.Report.Tap.BACKUP_VERIFY import cash.z.ecc.android.feedback.measure import cash.z.ecc.android.lockbox.LockBox +import cash.z.ecc.android.sdk.ext.ZcashSdk +import cash.z.ecc.android.sdk.ext.twig import cash.z.ecc.android.ui.base.BaseFragment import cash.z.ecc.android.ui.setup.WalletSetupViewModel.LockBoxKey import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.SEED_WITH_BACKUP import cash.z.ecc.android.ui.util.AddressPartNumberSpan import cash.z.ecc.kotlin.mnemonic.Mnemonics import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -82,10 +85,22 @@ class BackupFragment : BaseFragment() { override fun onResume() { super.onResume() resumedScope.launch { - binding.textBirtdate.text = "Birthday Height: %,d".format(walletSetup.loadBirthdayHeight()) + binding.textBirtdate.text = "Birthday Height: %,d".format(calculateBirthday()) } } + private suspend fun calculateBirthday(): Int { + var storedBirthday: Int = 0 + var oldestTransactionHeight:Int = 0 + try { + storedBirthday = walletSetup.loadBirthdayHeight() + oldestTransactionHeight = mainActivity?.synchronizerComponent?.synchronizer()?.receivedTransactions?.first()?.last()?.minedHeight ?: 0 + } catch (t: Throwable) { + twig("failed to calculate birthday due to: $t") + } + return maxOf(storedBirthday, oldestTransactionHeight, ZcashSdk.SAPLING_ACTIVATION_HEIGHT) + } + private fun onEnterWallet(showMessage: Boolean = !this.hasBackUp) { if (showMessage) { Toast.makeText(activity, "Backup verification coming soon!", Toast.LENGTH_LONG).show() From 3a336396a0b97d565e8fcd67f59a92d60bc5e6a0 Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Sat, 1 Aug 2020 02:50:25 -0400 Subject: [PATCH 05/10] New: added convenience function to get the latest height. This is cached in the processor so we're basically just bubbling it up. No processing is needed to return this value. --- app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt b/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt index ceb57be..2e573fd 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt @@ -78,6 +78,12 @@ class MainActivity : AppCompatActivity() { Manifest.permission.CAMERA ) == PackageManager.PERMISSION_GRANTED + val latestHeight: Int? get() = if (::synchronizerComponent.isInitialized) { + synchronizerComponent.synchronizer().latestHeight + } else { + null + } + override fun onCreate(savedInstanceState: Bundle?) { component = ZcashWalletApp.component.mainActivitySubcomponent().create(this).also { it.inject(this) From a9bc645cb1678514f4813300e75e2b51ec48f543 Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Sat, 1 Aug 2020 02:53:01 -0400 Subject: [PATCH 06/10] Fix: wallet history now correctly scrolls to the top. Previously, it would chop off the last transaction which happens to be what users are most interested in seeing. This was happening because the scroll was being called before the list contents were fully loaded from the database so we were scrolling to the top of an empty list. --- .../android/ui/detail/WalletDetailFragment.kt | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/cash/z/ecc/android/ui/detail/WalletDetailFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/detail/WalletDetailFragment.kt index 8595cb3..e02767e 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/detail/WalletDetailFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/detail/WalletDetailFragment.kt @@ -60,19 +60,39 @@ class WalletDetailFragment : BaseFragment() { } private fun initTransactionUI() { - binding.recyclerTransactions.layoutManager = - LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false) - binding.recyclerTransactions.addItemDecoration(TransactionsFooter(binding.recyclerTransactions.context)) adapter = TransactionAdapter() + binding.recyclerTransactions.apply { + layoutManager = + LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false) + addItemDecoration(TransactionsFooter(binding.recyclerTransactions.context)) + adapter = this@WalletDetailFragment.adapter + scrollToTop() + } viewModel.transactions.collectWith(resumedScope) { onTransactionsUpdated(it) } - binding.recyclerTransactions.adapter = adapter - binding.recyclerTransactions.smoothScrollToPosition(0) } private fun onTransactionsUpdated(transactions: PagedList) { twig("got a new paged list of transactions") - binding.groupEmptyViews.goneIf(transactions.size > 0) - adapter.submitList(transactions) + transactions.size.let { newCount -> + binding.groupEmptyViews.goneIf(newCount > 0) + val preSize = adapter.itemCount + adapter.submitList(transactions) + // don't rescroll while the user is looking at the list, unless it's initialization + // using 4 here because there might be headers or other things that make 0 a bad pick + // 4 is about how many can fit before scrolling becomes necessary on many screens + if (preSize < 4 && newCount > preSize) { + scrollToTop() + } + } + } + + private fun scrollToTop() { + twig("scrolling to the top") + binding.recyclerTransactions.apply { + postDelayed({ + smoothScrollToPosition(0) + }, 5L) + } } // TODO: maybe implement this for better fade behavior. Or do an actual scroll behavior instead, yeah do that. Or an item decoration. From 92b19820cb4db55f6bec0e848ba0a97ca4de8bee Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Sat, 1 Aug 2020 02:56:42 -0400 Subject: [PATCH 07/10] Fix: amount not clearing when returning to the home screen. After a transaction was sent, the old value would linger. That's fine in the case of a failure so the user can try again but it is not okay after completing a transaction. The issue was that the fragment was staying in memory and it's UiModel was still available. The fix is to let the SendViewModel be the source of truth about what amount the user has entered. --- .../main/java/cash/z/ecc/android/ui/home/HomeFragment.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt index 731446e..f2d8da9 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt @@ -115,12 +115,14 @@ class HomeFragment : BaseFragment() { } if (::uiModel.isInitialized) { - twig("uiModel exists!") - onModelUpdated(null, uiModel) + twig("uiModel exists! it has pendingSend=${uiModel.pendingSend} ZEC while the sendViewModel=${sendViewModel.zatoshiAmount} zats") + // if the model already existed, cool but let the sendViewModel be the source of truth for the amount + onModelUpdated(null, uiModel.copy(pendingSend = sendViewModel.zatoshiAmount.coerceAtLeast(0).convertZatoshiToZecStringUniform(8))) } } private fun onClearAmount() { + twig("onClearAmount()") if (::uiModel.isInitialized) { resumedScope.launch { binding.textSendAmount.text.apply { @@ -228,6 +230,7 @@ class HomeFragment : BaseFragment() { * @param amount the amount to send represented as ZEC, without the dollar sign. */ fun setSendAmount(amount: String, updateModel: Boolean = true) { + twig("setSendAmount($amount, $updateModel)") binding.textSendAmount.text = "\$$amount".toColoredSpan(R.color.text_light_dimmed, "$") if (updateModel) { sendViewModel.zatoshiAmount = amount.safelyConvertToBigDecimal().convertZecToZatoshi() From 002152c8470d2d9ac3868f4cb8b12441777cd502 Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Sat, 1 Aug 2020 02:57:38 -0400 Subject: [PATCH 08/10] New: added view utilities. --- app/src/main/java/cash/z/ecc/android/ext/View.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/cash/z/ecc/android/ext/View.kt b/app/src/main/java/cash/z/ecc/android/ext/View.kt index 4bfec90..3947181 100644 --- a/app/src/main/java/cash/z/ecc/android/ext/View.kt +++ b/app/src/main/java/cash/z/ecc/android/ext/View.kt @@ -6,9 +6,19 @@ import cash.z.ecc.android.ui.MainActivity import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.channelFlow -fun View.gone() = goneIf(true) +fun View.gone() { + visibility = GONE +} -fun View.invisible() = invisibleIf(true) +fun View.invisible() { + visibility = INVISIBLE +} + +fun View.visible() { + visibility = VISIBLE +} + +// NOTE: avoid `visibleIf` function because the false case is ambiguous: would it be gone or invisible? fun View.goneIf(isGone: Boolean) { visibility = if (isGone) GONE else VISIBLE From 00ccb674aaff30007714dfbb44ddd1c935c891a0 Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Sat, 1 Aug 2020 03:00:52 -0400 Subject: [PATCH 09/10] New: added expired state and mined height to details view. Showing the mined height is helpful so users can more easily select their birthday height, when needed. --- .../ecc/android/ui/detail/TransactionViewHolder.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionViewHolder.kt b/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionViewHolder.kt index b50a79a..83815a2 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionViewHolder.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionViewHolder.kt @@ -7,11 +7,15 @@ import androidx.recyclerview.widget.RecyclerView import cash.z.ecc.android.R import cash.z.ecc.android.ext.goneIf import cash.z.ecc.android.ext.toAppColor +import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction +import cash.z.ecc.android.sdk.db.entity.PendingTransactionEntity +import cash.z.ecc.android.sdk.ext.ZcashSdk +import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString +import cash.z.ecc.android.sdk.ext.isShielded +import cash.z.ecc.android.sdk.ext.toAbbreviatedAddress import cash.z.ecc.android.ui.MainActivity import cash.z.ecc.android.ui.util.INCLUDE_MEMO_PREFIXES_RECOGNIZED import cash.z.ecc.android.ui.util.toUtf8Memo -import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction -import cash.z.ecc.android.sdk.ext.* import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.launch import java.nio.charset.Charset @@ -55,6 +59,8 @@ class TransactionViewHolder(itemView: View) : Recycler !toAddress.isNullOrEmpty() -> { lineOne = "You paid ${toAddress?.toAbbreviatedAddress()}" lineTwo = if (isMined) "Sent $timestamp" else "Pending confirmation" + // TODO: this logic works but is sloppy. Find a more robust solution to displaying information about expiration (such as expires in 1 block, etc). Then if it is way beyond expired, remove it entirely. Perhaps give the user a button for that (swipe to dismiss?) + if(!isMined && (expiryHeight != null) && (expiryHeight!! < (itemView.context as MainActivity).latestHeight ?: -1)) lineTwo = "Expired" amountDisplay = "- $amountZec" if (isMined) { amountColor = R.color.zcashRed @@ -94,6 +100,8 @@ class TransactionViewHolder(itemView: View) : Recycler bottomText.setTextColor(lineTwoColor.toAppColor()) val context = itemView.context indicator.background = context.resources.getDrawable(indicatorBackground) + + // TODO: change this so we see the shield if it is a z-addr in the address line but verify the intended design/behavior, first shieldIcon.goneIf((transaction?.raw != null || transaction?.expiryHeight != null) && !transaction?.toAddress.isShielded()) } } @@ -127,6 +135,7 @@ class TransactionViewHolder(itemView: View) : Recycler private fun onTransactionClicked(transaction: ConfirmedTransaction) { val txId = transaction.rawTransactionId.toTxId() val detailsMessage: String = "Zatoshi amount: ${transaction.value}\n\n" + + "Mined height: ${transaction.minedHeight}\n\n" + "Transaction: $txId" + "${if (transaction.toAddress != null) "\n\nTo: ${transaction.toAddress}" else ""}" + "${if (transaction.memo != null) "\n\nMemo: \n${String(transaction.memo!!, Charset.forName("UTF-8"))}" else ""}" From 20c3317564da7976cf43f79e13a66ebe044fefaf Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Sat, 1 Aug 2020 03:02:45 -0400 Subject: [PATCH 10/10] New: Entirely revamped the send flow. Before it was more of a wizard but now it is all done on one screen. Indirectly, this also made 'cancellation' a first-class citizen, again. --- .../cash/z/ecc/android/feedback/Report.kt | 3 + .../z/ecc/android/ui/scan/ScanFragment.kt | 2 +- .../android/ui/send/SendAddressFragment.kt | 24 +- .../android/ui/send/SendConfirmFragment.kt | 10 +- .../ecc/android/ui/send/SendFinalFragment.kt | 145 +++--- .../z/ecc/android/ui/send/SendFragment.kt | 309 +++++++++++ .../z/ecc/android/ui/send/SendMemoFragment.kt | 17 +- .../z/ecc/android/ui/send/SendViewModel.kt | 28 +- .../main/res/drawable/ic_baseline_done_24.xml | 5 + .../ic_baseline_keyboard_arrow_down_24.xml | 5 + app/src/main/res/layout/fragment_home.xml | 19 +- app/src/main/res/layout/fragment_send.xml | 489 ++++++++++++++++++ .../main/res/layout/fragment_send_final.xml | 166 +++--- .../main/res/navigation/mobile_navigation.xml | 89 ++-- app/src/main/res/raw/lottie_sending.json | 1 + app/src/main/res/values/strings.xml | 17 +- app/src/main/res/values/styles.xml | 7 +- 17 files changed, 1129 insertions(+), 207 deletions(-) create mode 100644 app/src/main/java/cash/z/ecc/android/ui/send/SendFragment.kt create mode 100644 app/src/main/res/drawable/ic_baseline_done_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_keyboard_arrow_down_24.xml create mode 100644 app/src/main/res/layout/fragment_send.xml create mode 100644 app/src/main/res/raw/lottie_sending.json diff --git a/app/src/main/java/cash/z/ecc/android/feedback/Report.kt b/app/src/main/java/cash/z/ecc/android/feedback/Report.kt index f43f7a9..9c419bb 100644 --- a/app/src/main/java/cash/z/ecc/android/feedback/Report.kt +++ b/app/src/main/java/cash/z/ecc/android/feedback/Report.kt @@ -140,6 +140,7 @@ object Report { SEND_ADDRESS_MAX("send.address.max"), SEND_ADDRESS_NEXT("send.address.next"), SEND_ADDRESS_PASTE("send.address.paste"), + SEND_ADDRESS_REUSE("send.address.reuse"), SEND_ADDRESS_BACK("send.address.back"), SEND_ADDRESS_DONE_ADDRESS("send.address.done.address"), SEND_ADDRESS_DONE_AMOUNT("send.address.done.amount"), @@ -156,6 +157,8 @@ object Report { SEND_MEMO_CLEAR("send.memo.clear"), SEND_MEMO_BACK("send.memo.back"), + SEND_SUBMIT("send.submit"), + // General events COPY_ADDRESS("copy.address"); diff --git a/app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt index 602a028..4d8142a 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt @@ -133,7 +133,7 @@ class ScanFragment : BaseFragment() { } else { /* continue scanning*/ binding.textScanError.text = "" sendViewModel.toAddress = qrContent - mainActivity?.safeNavigate(R.id.action_nav_scan_to_nav_send_address) + mainActivity?.safeNavigate(R.id.action_nav_scan_to_nav_send) } } } diff --git a/app/src/main/java/cash/z/ecc/android/ui/send/SendAddressFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/send/SendAddressFragment.kt index f311117..33d263a 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/send/SendAddressFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/send/SendAddressFragment.kt @@ -106,18 +106,18 @@ class SendAddressFragment : BaseFragment(), private fun onSubmit(unused: EditText? = null) { sendViewModel.toAddress = binding.inputZcashAddress.text.toString() binding.inputZcashAmount.convertZecToZatoshi()?.let { sendViewModel.zatoshiAmount = it } - sendViewModel.validate(maxZatoshi).onFirstWith(resumedScope) { - if (it == null) { - sendViewModel.funnel(Send.AddressPageComplete) - mainActivity?.safeNavigate(R.id.action_nav_send_address_to_send_memo) - } else { - resumedScope.launch { - binding.textAddressError.text = it - delay(1500L) - binding.textAddressError.text = "" - } - } - } +// sendViewModel.validate(maxZatoshi).onFirstWith(resumedScope) { +// if (it == null) { +// sendViewModel.funnel(Send.AddressPageComplete) +//// mainActivity?.safeNavigate(R.id.action_nav_send_address_to_send_memo) +// } else { +// resumedScope.launch { +// binding.textAddressError.text = it +// delay(1500L) +// binding.textAddressError.text = "" +// } +// } +// } } private fun onMax() { diff --git a/app/src/main/java/cash/z/ecc/android/ui/send/SendConfirmFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/send/SendConfirmFragment.kt index 73d6fea..ea0544b 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/send/SendConfirmFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/send/SendConfirmFragment.kt @@ -30,10 +30,10 @@ class SendConfirmFragment : BaseFragment() { binding.buttonNext.setOnClickListener { onSend().also { tapped(SEND_CONFIRM_NEXT) } } - R.id.action_nav_send_confirm_to_nav_send_memo.let { - binding.backButtonHitArea.onClickNavTo(it) { tapped(SEND_CONFIRM_BACK) } - onBackPressNavTo(it) { tapped(SEND_CONFIRM_BACK) } - } +// R.id.action_nav_send_confirm_to_nav_send_memo.let { +// binding.backButtonHitArea.onClickNavTo(it) { tapped(SEND_CONFIRM_BACK) } +// onBackPressNavTo(it) { tapped(SEND_CONFIRM_BACK) } +// } mainActivity?.lifecycleScope?.launch { binding.textConfirmation.text = "Send ${sendViewModel.zatoshiAmount.convertZatoshiToZecString(8)} ZEC to ${sendViewModel?.toAddress.toAbbreviatedAddress()}?" @@ -46,6 +46,6 @@ class SendConfirmFragment : BaseFragment() { private fun onSend() { sendViewModel.funnel(Send.ConfirmPageComplete) - mainActivity?.safeNavigate(R.id.action_nav_send_confirm_to_send_final) +// mainActivity?.safeNavigate(R.id.action_nav_send_confirm_to_send_final) } } \ No newline at end of file diff --git a/app/src/main/java/cash/z/ecc/android/ui/send/SendFinalFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/send/SendFinalFragment.kt index c4c11ea..f99b700 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/send/SendFinalFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/send/SendFinalFragment.kt @@ -10,17 +10,15 @@ import cash.z.ecc.android.databinding.FragmentSendFinalBinding import cash.z.ecc.android.di.viewmodel.activityViewModel import cash.z.ecc.android.ext.goneIf import cash.z.ecc.android.feedback.Report -import cash.z.ecc.android.feedback.Report.Tap.* -import cash.z.ecc.android.ui.base.BaseFragment +import cash.z.ecc.android.feedback.Report.Tap.SEND_FINAL_CLOSE +import cash.z.ecc.android.feedback.Report.Tap.SEND_FINAL_EXIT import cash.z.ecc.android.sdk.db.entity.* import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString import cash.z.ecc.android.sdk.ext.toAbbreviatedAddress import cash.z.ecc.android.sdk.ext.twig -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.flow +import cash.z.ecc.android.ui.base.BaseFragment import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlin.random.Random class SendFinalFragment : BaseFragment() { override val screen = Report.Screen.SEND_FINAL @@ -32,21 +30,17 @@ class SendFinalFragment : BaseFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.buttonNext.setOnClickListener { - onExit().also { tapped(SEND_FINAL_EXIT) } + binding.buttonPrimary.setOnClickListener { + onReturnToSend() } - binding.buttonRetry.setOnClickListener { - onRetry().also { tapped(SEND_FINAL_RETRY) } + binding.buttonSecondary.setOnClickListener { + onExit().also { tapped(SEND_FINAL_EXIT) } } binding.backButtonHitArea.setOnClickListener { onExit().also { tapped(SEND_FINAL_CLOSE) } } binding.textConfirmation.text = - "Sending ${sendViewModel.zatoshiAmount.convertZatoshiToZecString(8)} ZEC to ${sendViewModel.toAddress.toAbbreviatedAddress()}" - sendViewModel.memo.trim().isNotEmpty().let { hasMemo -> - binding.radioIncludeAddress.isChecked = hasMemo - binding.radioIncludeAddress.goneIf(!hasMemo) - } + "Sending ${sendViewModel.zatoshiAmount.convertZatoshiToZecString(8)} ZEC to\n${sendViewModel.toAddress.toAbbreviatedAddress()}" mainActivity?.preventBackPress(this) } @@ -55,60 +49,36 @@ class SendFinalFragment : BaseFragment() { mainActivity?.apply { sendViewModel.send().onEach { onPendingTxUpdated(it) - }.launchIn(mainActivity?.lifecycleScope!!) + }.launchIn(lifecycleScope) } } - override fun onResume() { - super.onResume() - flow { - val max = binding.progressHorizontal.max - 1 - var progress = 0 - while (progress < max) { - emit(progress) - delay(Random.nextLong(1000)) - progress++ - } - }.onEach { - binding.progressHorizontal.progress = it - }.launchIn(resumedScope) - } + private fun onPendingTxUpdated(tx: PendingTransaction?) { + if (tx == null) return // TODO: maybe log this - private fun onPendingTxUpdated(pendingTransaction: PendingTransaction?) { try { - if (pendingTransaction != null) sendViewModel.updateMetrics(pendingTransaction) - val id = pendingTransaction?.id ?: -1 - var isSending = true - var isFailure = false - var step: Report.Funnel.Send? = null - val message = when { - pendingTransaction == null -> "Transaction not found".also { step = Report.Funnel.Send.ErrorNotFound } - pendingTransaction.isMined() -> "Transaction Mined!\n\nSEND COMPLETE".also { isSending = false; step = Report.Funnel.Send.Mined(pendingTransaction.minedHeight) } - pendingTransaction.isSubmitSuccess() -> "Successfully submitted transaction!\nAwaiting confirmation . . .".also { step = Report.Funnel.Send.Submitted } - pendingTransaction.isFailedEncoding() -> "ERROR: failed to encode transaction! (id: $id)".also { isSending = false; isFailure = true; step = Report.Funnel.Send.ErrorEncoding(pendingTransaction?.errorCode, pendingTransaction?.errorMessage) } - pendingTransaction.isFailedSubmit() -> "ERROR: failed to submit transaction! (id: $id)".also { isSending = false; isFailure = true; step = Report.Funnel.Send.ErrorSubmitting(pendingTransaction?.errorCode, pendingTransaction?.errorMessage) } - pendingTransaction.isCreated() -> "Transaction creation complete!".also { step = Report.Funnel.Send.Created(id) } - pendingTransaction.isCreating() -> "Creating transaction . . .".also { step = Report.Funnel.Send.Creating } - else -> "Transaction updated!".also { twig("Unhandled TX state: $pendingTransaction") } + tx.toUiModel().let { model -> + binding.apply { + backButton.goneIf(!model.showCloseIcon) + backButtonHitArea.goneIf(!model.showCloseIcon) + buttonSecondary.goneIf(!model.showCloseIcon) + + textConfirmation.text = model.title + lottieSending.goneIf(!model.showProgress) + if (!model.showProgress) lottieSending.pauseAnimation() else lottieSending.playAnimation() + errorMessage.text = model.errorMessage + buttonPrimary.apply { + text = model.primaryButtonText + setOnClickListener { model.primaryAction() } + } + } } - sendViewModel.funnel(step) - - twig("Pending TX (id: ${pendingTransaction?.id} Updated with message: $message") - binding.textStatus.apply { - text = "$message" - } - binding.backButton.goneIf(!binding.textStatus.text.toString().contains("Awaiting")) - binding.buttonNext.goneIf((pendingTransaction?.isSubmitSuccess() != true) && (pendingTransaction?.isCreated() != true) && !isFailure) - binding.buttonNext.text = if (isSending) "Done" else "Finished" - binding.buttonRetry.goneIf(!isFailure) - binding.progressHorizontal.goneIf(!isSending) - - - if (pendingTransaction?.isSubmitSuccess() == true) { + // only hold onto the view model if the transaction failed so that the user can retry + if (tx.isSubmitSuccess()) { sendViewModel.reset() } - } catch(t: Throwable) { + } catch (t: Throwable) { val message = "ERROR: error while handling pending transaction update! $t" twig(message) mainActivity?.feedback?.report(Report.Error.NonFatal.TxUpdateFailed(t)) @@ -117,11 +87,64 @@ class SendFinalFragment : BaseFragment() { } private fun onExit() { + sendViewModel.reset() mainActivity?.navController?.popBackStack(R.id.nav_home, false) } - private fun onRetry() { - mainActivity?.navController?.popBackStack(R.id.nav_send_address, false) + private fun onCancel(tx: PendingTransaction) { + sendViewModel.cancel(tx.id) } + private fun onReturnToSend() { + mainActivity?.navController?.popBackStack(R.id.nav_send, false) + } + + private fun onSeeDetails() { + sendViewModel.reset() + mainActivity?.safeNavigate(R.id.action_nav_send_final_to_nav_detail) + } + + private fun PendingTransaction.toUiModel() = UiModel().also { model -> + when { + isCancelled() -> { + model.title = "Cancelled." + model.primaryButtonText = "Go Back" + model.primaryAction = { onReturnToSend() } + } + isSubmitSuccess() -> { + model.title = "SENT!" + model.primaryButtonText = "See Details" + model.primaryAction = { onSeeDetails() } + } + isFailure() -> { + model.title = "Failed." + model.errorMessage = if (isFailedEncoding()) "The transaction could not be encoded." else "Unable to submit transaction to the network." + model.primaryButtonText = "Retry" + model.primaryAction = { onReturnToSend() } + } + else -> { + model.title = "Sending ${value.convertZatoshiToZecString(8)} ZEC to\n${toAddress.toAbbreviatedAddress()}" + model.showProgress = true + if (isCreating()) { + model.showCloseIcon = false + model.primaryButtonText = "Cancel" + model.primaryAction = { onCancel(this) } + } else { + model.primaryButtonText = "See Details" + model.primaryAction = { onSeeDetails() } + } + } + } + } + + // fields are ordered, as they appear, top-to-bottom in the UI because that makes it easier to reason about each screen state + data class UiModel( + var showCloseIcon: Boolean = true, + var title: String = "", + var showProgress: Boolean = false, + var errorMessage: String = "", + var primaryButtonText: String = "See Details", + var primaryAction: () -> Unit = {} + ) + } diff --git a/app/src/main/java/cash/z/ecc/android/ui/send/SendFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/send/SendFragment.kt new file mode 100644 index 0000000..6d74911 --- /dev/null +++ b/app/src/main/java/cash/z/ecc/android/ui/send/SendFragment.kt @@ -0,0 +1,309 @@ +package cash.z.ecc.android.ui.send + +import android.content.ClipboardManager +import android.content.Context +import android.content.res.ColorStateList +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.widget.EditText +import androidx.biometric.BiometricConstants.* +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.core.widget.ImageViewCompat +import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.viewModelScope +import cash.z.ecc.android.R +import cash.z.ecc.android.databinding.FragmentSendBinding +import cash.z.ecc.android.di.viewmodel.activityViewModel +import cash.z.ecc.android.ext.* +import cash.z.ecc.android.feedback.Report +import cash.z.ecc.android.feedback.Report.Tap.* +import cash.z.ecc.android.sdk.block.CompactBlockProcessor.WalletBalance +import cash.z.ecc.android.sdk.ext.* +import cash.z.ecc.android.sdk.validate.AddressType +import cash.z.ecc.android.ui.base.BaseFragment +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import java.util.concurrent.Executor + +class SendFragment : BaseFragment(), + ClipboardManager.OnPrimaryClipChangedListener { + override val screen = Report.Screen.SEND_ADDRESS + + private var maxZatoshi: Long? = null + private var availableZatoshi: Long? = null + + val sendViewModel: SendViewModel by activityViewModel() + + override fun inflate(inflater: LayoutInflater): FragmentSendBinding = + FragmentSendBinding.inflate(inflater) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Apply View Model + applyViewModel(sendViewModel) + + + // Apply behaviors + + binding.buttonSend.setOnClickListener { + onSubmit().also { tapped(SEND_SUBMIT) } + } + + binding.checkIncludeAddress.setOnCheckedChangeListener { _, _-> + onIncludeMemo(binding.checkIncludeAddress.isChecked) + } + + binding.inputZcashAddress.apply { + doAfterTextChanged { + val textStr = text.toString() + val trim = textStr.trim() + // bugfix: prevent cursor from moving while backspacing and deleting whitespace + if (text.toString() != trim) { + setText(trim) + setSelection(selectionEnd - (textStr.length - trim.length)) + } + onAddressChanged(trim) + } + } + + binding.backButtonHitArea.onClickNavUp { tapped(SEND_ADDRESS_BACK) } +// +// binding.clearMemo.setOnClickListener { +// onClearMemo().also { tapped(SEND_MEMO_CLEAR) } +// } + + binding.inputZcashMemo.doAfterTextChanged { + sendViewModel.memo = binding.inputZcashMemo.text?.toString() ?: "" + onMemoUpdated() + } + + binding.textLayoutAddress.setEndIconOnClickListener { + mainActivity?.maybeOpenScan().also { tapped(SEND_ADDRESS_SCAN) } + } + + // banners + + binding.backgroundClipboard.setOnClickListener { + onPaste().also { tapped(SEND_ADDRESS_PASTE) } + } + binding.containerClipboard.setOnClickListener { + onPaste().also { tapped(SEND_ADDRESS_PASTE) } + } + binding.backgroundLastUsed.setOnClickListener { + onReuse().also { tapped(SEND_ADDRESS_REUSE) } + } + binding.containerLastUsed.setOnClickListener { + onReuse().also { tapped(SEND_ADDRESS_REUSE) } + } + } + + private fun applyViewModel(model: SendViewModel) { + // apply amount + val roundedAmount = + model.zatoshiAmount.coerceAtLeast(0L).convertZatoshiToZecStringUniform(8) + binding.textSendAmount.text = "\$$roundedAmount" + // apply address + binding.inputZcashAddress.setText(model.toAddress) + // apply memo + binding.inputZcashMemo.setText(model.memo) + binding.checkIncludeAddress.isChecked = model.includeFromAddress + onMemoUpdated() + } + + private fun onMemoUpdated() { + val totalLength = sendViewModel.createMemoToSend().length + binding.textLayoutMemo.helperText = "$totalLength/${ZcashSdk.MAX_MEMO_SIZE} chars" + val color = if (totalLength > ZcashSdk.MAX_MEMO_SIZE) R.color.zcashRed else R.color.text_light_dimmed + binding.textLayoutMemo.setHelperTextColor(ColorStateList.valueOf(color.toAppColor())) + } + + private fun onClearMemo() { + binding.inputZcashMemo.setText("") + } + + private fun onIncludeMemo(checked: Boolean) { + sendViewModel.afterInitFromAddress { + sendViewModel.includeFromAddress = checked + onMemoUpdated() + tapped(if (checked) SEND_MEMO_INCLUDE else SEND_MEMO_EXCLUDE) + } + } + + private fun onAddressChanged(address: String) { + resumedScope.launch { + var type = when (sendViewModel.validateAddress(address)) { + is AddressType.Transparent -> "This is a valid transparent address" to R.color.zcashGreen + is AddressType.Shielded -> "This is a valid shielded address" to R.color.zcashGreen + is AddressType.Invalid -> "This address appears to be invalid" to R.color.zcashRed + } + if (address == sendViewModel.synchronizer.getAddress()) type = + "Warning, this appears to be your address!" to R.color.zcashRed + binding.textLayoutAddress.helperText = type.first + binding.textLayoutAddress.setHelperTextColor(ColorStateList.valueOf(type.second.toAppColor())) + + // if we have the clipboard address but we're changing it, then clear the selection + if (binding.imageClipboardAddressSelected.isVisible) { + loadAddressFromClipboard().let { clipboardAddress -> + if (address != clipboardAddress) { + updateClipboardBanner(false, clipboardAddress) + } + } + } + // if we have the last used address but we're changing it, then clear the selection + if (binding.imageLastUsedAddressSelected.isVisible) { + loadLastUsedAddress().let { lastAddress -> + if (address != lastAddress) { + updateLastUsedBanner(false, lastAddress) + } + } + } + } + } + + + private fun onSubmit(unused: EditText? = null) { + sendViewModel.toAddress = binding.inputZcashAddress.text.toString() + sendViewModel.validate(availableZatoshi, maxZatoshi).onFirstWith(resumedScope) { errorMessage -> + if (errorMessage == null) { + mainActivity?.authenticate("Please confirm that you want to send ${sendViewModel.zatoshiAmount.convertZatoshiToZecString(8)} ZEC to\n${sendViewModel.toAddress.toAbbreviatedAddress()}") { +// sendViewModel.funnel(Send.AddressPageComplete) + mainActivity?.safeNavigate(R.id.action_nav_send_to_nav_send_final) + } + } else { + resumedScope.launch { + binding.textAddressError.text = errorMessage + delay(1500L) + binding.textAddressError.text = "" + } + } + } + } + + private fun onMax() { + if (maxZatoshi != null) { +// binding.inputZcashAmount.apply { +// setText(maxZatoshi.convertZatoshiToZecString(8)) +// postDelayed({ +// requestFocus() +// setSelection(text?.length ?: 0) +// }, 10L) +// } + } + } + + + override fun onAttach(context: Context) { + super.onAttach(context) + mainActivity?.clipboard?.addPrimaryClipChangedListener(this) + } + + override fun onDetach() { + super.onDetach() + mainActivity?.clipboard?.removePrimaryClipChangedListener(this) + } + + override fun onResume() { + super.onResume() + updateClipboardBanner() + updateLastUsedBanner() + sendViewModel.synchronizer.balances.collectWith(resumedScope) { + onBalanceUpdated(it) + } + binding.inputZcashAddress.text.toString().let { + if (!it.isNullOrEmpty()) onAddressChanged(it) + } + } + + private fun onBalanceUpdated(balance: WalletBalance) { +// binding.textLayoutAmount.helperText = +// "You have ${balance.availableZatoshi.coerceAtLeast(0L).convertZatoshiToZecString(8)} available" + maxZatoshi = (balance.availableZatoshi - ZcashSdk.MINERS_FEE_ZATOSHI).coerceAtLeast(0L) + availableZatoshi = balance.availableZatoshi + } + + override fun onPrimaryClipChanged() { + twig("clipboard changed!") + updateClipboardBanner() + updateLastUsedBanner() + } + + private fun updateClipboardBanner(selected: Boolean = false, address: String? = loadAddressFromClipboard()) { + if (address == null) { + binding.groupClipboard.gone() + } else { + binding.groupClipboard.visible() + binding.clipboardAddress.text = address.toAbbreviatedAddress(16, 16) + binding.imageClipboardAddressSelected.goneIf(!selected) + ImageViewCompat.setImageTintList(binding.imageShield, ColorStateList.valueOf(if (selected) R.color.colorPrimary.toAppColor() else R.color.zcashWhite_12.toAppColor())) + binding.clipboardAddressLabel.setTextColor(if(selected) R.color.colorPrimary.toAppColor() else R.color.text_light.toAppColor()) + binding.clipboardAddress.setTextColor(if(selected) R.color.text_light.toAppColor() else R.color.text_light_dimmed.toAppColor()) + } + } + + private fun updateLastUsedBanner(selected: Boolean = false, address: String? = loadLastUsedAddress()) { + if (address == null || address == loadAddressFromClipboard()) { + binding.groupLastUsed.gone() + } else { + binding.groupLastUsed.visible() + binding.lastUsedAddress.text = address.toAbbreviatedAddress(16, 16) + binding.imageLastUsedAddressSelected.goneIf(!selected) + ImageViewCompat.setImageTintList(binding.imageLastUsedShield, ColorStateList.valueOf(if (selected) R.color.colorPrimary.toAppColor() else R.color.zcashWhite_12.toAppColor())) + binding.lastUsedAddressLabel.setTextColor(if(selected) R.color.colorPrimary.toAppColor() else R.color.text_light.toAppColor()) + binding.lastUsedAddress.setTextColor(if(selected) R.color.text_light.toAppColor() else R.color.text_light_dimmed.toAppColor()) + } + } + + private fun onPaste() { + mainActivity?.clipboard?.let { clipboard -> + if (clipboard.hasPrimaryClip()) { + val address = clipboard.text().toString() + val applyValue = binding.imageClipboardAddressSelected.isGone + updateClipboardBanner(applyValue, address) + binding.inputZcashAddress.setText(address.takeUnless { !applyValue }) + } + } + } + + private fun onReuse() { + val address = loadLastUsedAddress() + val applyValue = binding.imageLastUsedAddressSelected.isGone + updateLastUsedBanner(applyValue, address) + binding.inputZcashAddress.setText(address.takeUnless { !applyValue }) + } + + private fun loadAddressFromClipboard(): String? { + mainActivity?.clipboard?.apply { + if (hasPrimaryClip()) { + text()?.let { text -> + if (text.startsWith("zs") && text.length > 70) { + return@loadAddressFromClipboard text.toString() + } + // treat t-addrs differently in the future + if (text.startsWith("t1") && text.length > 32) { + return@loadAddressFromClipboard text.toString() + } + } + } + } + return null + } + + private var lastUsedAddress: String? = null + private fun loadLastUsedAddress(): String? { + if (lastUsedAddress == null) sendViewModel.viewModelScope.launch { + lastUsedAddress = sendViewModel.synchronizer.sentTransactions.first().firstOrNull { !it.toAddress.isNullOrEmpty() }?.toAddress + updateLastUsedBanner(binding.imageLastUsedAddressSelected.isVisible, lastUsedAddress) + } + return lastUsedAddress + } + + + private fun ClipboardManager.text(): CharSequence = + primaryClip!!.getItemAt(0).coerceToText(mainActivity) +} \ No newline at end of file diff --git a/app/src/main/java/cash/z/ecc/android/ui/send/SendMemoFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/send/SendMemoFragment.kt index 1f864b9..10884dd 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/send/SendMemoFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/send/SendMemoFragment.kt @@ -9,13 +9,12 @@ import cash.z.ecc.android.databinding.FragmentSendMemoBinding import cash.z.ecc.android.di.viewmodel.activityViewModel import cash.z.ecc.android.ext.gone import cash.z.ecc.android.ext.goneIf -import cash.z.ecc.android.ext.onClickNavTo import cash.z.ecc.android.ext.onEditorActionDone import cash.z.ecc.android.feedback.Report import cash.z.ecc.android.feedback.Report.Funnel.Send import cash.z.ecc.android.feedback.Report.Tap.* import cash.z.ecc.android.ui.base.BaseFragment -import cash.z.ecc.android.ui.util.INCLUDE_MEMO_PREFIX +import cash.z.ecc.android.ui.util.INCLUDE_MEMO_PREFIX_STANDARD class SendMemoFragment : BaseFragment() { override val screen = Report.Screen.SEND_MEMO @@ -37,10 +36,10 @@ class SendMemoFragment : BaseFragment() { onClearMemo().also { tapped(SEND_MEMO_CLEAR) } } - R.id.action_nav_send_memo_to_nav_send_address.let { - binding.backButtonHitArea.onClickNavTo(it) { tapped(SEND_MEMO_BACK) } - onBackPressNavTo(it) { tapped(SEND_MEMO_BACK) } - } +// R.id.action_nav_send_memo_to_nav_send_address.let { +// binding.backButtonHitArea.onClickNavTo(it) { tapped(SEND_MEMO_BACK) } +// onBackPressNavTo(it) { tapped(SEND_MEMO_BACK) } +// } binding.checkIncludeAddress.setOnCheckedChangeListener { _, _-> onIncludeMemo(binding.checkIncludeAddress.isChecked) @@ -56,7 +55,7 @@ class SendMemoFragment : BaseFragment() { } sendViewModel.afterInitFromAddress { - binding.textIncludedAddress.text = "$INCLUDE_MEMO_PREFIX ${sendViewModel.fromAddress}" + binding.textIncludedAddress.text = "$INCLUDE_MEMO_PREFIX_STANDARD ${sendViewModel.fromAddress}" } binding.textIncludedAddress.gone() @@ -103,7 +102,7 @@ class SendMemoFragment : BaseFragment() { sendViewModel.memo = binding.inputMemo.text.toString() onNext() } else { - mainActivity?.safeNavigate(R.id.action_nav_send_memo_to_nav_send_address) +// mainActivity?.safeNavigate(R.id.action_nav_send_memo_to_nav_send_address) } } @@ -116,6 +115,6 @@ class SendMemoFragment : BaseFragment() { private fun onNext() { sendViewModel.funnel(Send.MemoPageComplete) - mainActivity?.safeNavigate(R.id.action_nav_send_memo_to_send_confirm) +// mainActivity?.safeNavigate(R.id.action_nav_send_memo_to_send_confirm) } } \ No newline at end of file diff --git a/app/src/main/java/cash/z/ecc/android/ui/send/SendViewModel.kt b/app/src/main/java/cash/z/ecc/android/ui/send/SendViewModel.kt index 5a01a9f..82f9954 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/send/SendViewModel.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/send/SendViewModel.kt @@ -13,7 +13,7 @@ import cash.z.ecc.android.feedback.Report.MetricType import cash.z.ecc.android.feedback.Report.MetricType.* import cash.z.ecc.android.lockbox.LockBox import cash.z.ecc.android.ui.setup.WalletSetupViewModel -import cash.z.ecc.android.ui.util.INCLUDE_MEMO_PREFIX +import cash.z.ecc.android.ui.util.INCLUDE_MEMO_PREFIX_STANDARD import cash.z.ecc.android.sdk.Initializer import cash.z.ecc.android.sdk.Synchronizer import cash.z.ecc.android.sdk.db.entity.* @@ -23,6 +23,7 @@ import cash.z.ecc.android.sdk.ext.twig import cash.z.ecc.android.sdk.validate.AddressType import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -73,11 +74,17 @@ class SendViewModel @Inject constructor() : ViewModel() { toAddress, memoToSend.chunked(ZcashSdk.MAX_MEMO_SIZE).firstOrNull() ?: "" ).onEach { - twig(it.toString()) + twig("Received pending txUpdate: ${it?.toString()}") } } - fun createMemoToSend() = if (includeFromAddress) "$memo\n$INCLUDE_MEMO_PREFIX\n$fromAddress" else memo + fun cancel(pendingId: Long) { + viewModelScope.launch { + synchronizer.cancelSpend(pendingId) + } + } + + fun createMemoToSend() = if (includeFromAddress) "$memo\n$INCLUDE_MEMO_PREFIX_STANDARD\n$fromAddress" else memo private fun reportIssues(memoToSend: String) { if (toAddress == fromAddress) feedback.report(Issue.SelfSend) @@ -97,17 +104,26 @@ class SendViewModel @Inject constructor() : ViewModel() { suspend fun validateAddress(address: String): AddressType = synchronizer.validateAddress(address) - fun validate(maxZatoshi: Long?) = flow { + fun validate(availableZatoshi: Long?, maxZatoshi: Long?) = flow { when { synchronizer.validateAddress(toAddress).isNotValid -> { emit("Please enter a valid address.") } zatoshiAmount < 1 -> { - emit("Please enter at least 1 Zatoshi.") + emit("Please go back and enter at least 1 Zatoshi.") + } + availableZatoshi == null -> { + emit("Available funds not found. Please try again in a moment.") + } + availableZatoshi == 0L -> { + emit("No funds available to send.") + } + availableZatoshi > 0 && availableZatoshi < ZcashSdk.MINERS_FEE_ZATOSHI -> { + emit("Insufficient funds to cover miner's fee.") } maxZatoshi != null && zatoshiAmount > maxZatoshi -> { - emit( "Please enter no more than ${maxZatoshi.convertZatoshiToZecString(8)} ZEC.") + emit( "Please go back and enter no more than ${maxZatoshi.convertZatoshiToZecString(8)} ZEC.") } createMemoToSend().length > ZcashSdk.MAX_MEMO_SIZE -> { emit( "Memo must be less than ${ZcashSdk.MAX_MEMO_SIZE} in length.") diff --git a/app/src/main/res/drawable/ic_baseline_done_24.xml b/app/src/main/res/drawable/ic_baseline_done_24.xml new file mode 100644 index 0000000..2728880 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_done_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_down_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_down_24.xml new file mode 100644 index 0000000..1aeaa99 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_down_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 965c89a..0b2c844 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -342,6 +342,23 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="@id/guideline_hit_area_top" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_send_final.xml b/app/src/main/res/layout/fragment_send_final.xml index bdd758a..90525eb 100644 --- a/app/src/main/res/layout/fragment_send_final.xml +++ b/app/src/main/res/layout/fragment_send_final.xml @@ -2,12 +2,11 @@ + android:background="@drawable/background_send_final"> + app:srcCompat="@drawable/ic_close_black_24dp" + tools:visibility="visible" /> + app:layout_constraintVertical_bias="0.21" + tools:text="Send 12.345 ZEC to\nzs1g7sqw...mvyzgm?" /> + + + android:textAppearance="@style/TextAppearance.MaterialComponents.Body1" + app:layout_constraintTop_toBottomOf="@id/lottie_sending" + app:layout_constraintBottom_toTopOf="@id/button_primary" + app:layout_constraintStart_toStartOf="@id/button_primary" + app:layout_constraintEnd_toEndOf="@id/button_primary" + /> + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + style="@style/Zcash.Button.OutlinedButton" + android:padding="20dp" + android:translationY="-6dp" + android:text="@string/cancel" + android:textColor="@color/text_dark" + app:layout_constraintBottom_toTopOf="@id/button_secondary" + app:layout_constraintEnd_toEndOf="@id/guide_keys" + app:layout_constraintStart_toStartOf="@id/guide_keys" + app:layout_constraintTop_toBottomOf="@id/guide_keys" + app:layout_constraintVertical_bias="0.2" + app:layout_constraintVertical_chainStyle="packed" + app:strokeColor="@color/text_dark" /> - - - - - + app:layout_constraintTop_toBottomOf="@id/button_primary" /> \ No newline at end of file diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index 6eb8400..d2eed48 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -22,7 +22,7 @@ app:destination="@id/nav_landing" /> + android:id="@+id/nav_send" + android:name="cash.z.ecc.android.ui.send.SendFragment" + tools:layout="@layout/fragment_send" > - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/raw/lottie_sending.json b/app/src/main/res/raw/lottie_sending.json new file mode 100644 index 0000000..e1dc21b --- /dev/null +++ b/app/src/main/res/raw/lottie_sending.json @@ -0,0 +1 @@ +{"v":"5.5.8","fr":60,"ip":0,"op":90,"w":170,"h":130,"nm":"loader3","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":20,"s":[0]},{"t":89,"s":[180]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.145,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[34,65,0],"to":[0,-2.917,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.588},"o":{"x":0.732,"y":0},"t":20,"s":[34,47.5,0],"to":[0,0,0],"ti":[-55.765,0.067,0]},{"i":{"x":0.096,"y":1},"o":{"x":0.167,"y":0.414},"t":45,"s":[90.504,121.958,0],"to":[42.185,-0.051,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.984,"y":0},"t":70,"s":[139,40,0],"to":[0,0,0],"ti":[0,-0.833,0]},{"t":90,"s":[139,65,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":20,"s":[20,20]},{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":45,"s":[15,15]},{"t":70,"s":[20,20]}],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":3,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.125490196078,0.125490196078,0.133333333333,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('CHANGE COLORS').layer('CHANGE COLOR').content('CHANGE COLOR HERE').color;"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":90,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.408,"y":1},"o":{"x":0.518,"y":0},"t":35,"s":[139,65,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.396,"y":1},"o":{"x":0.461,"y":0},"t":59,"s":[94,65,0],"to":[0,0,0],"ti":[0,0,0]},{"t":83,"s":[104,65,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"rc","d":1,"s":{"a":0,"k":[20,20],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":3,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.125490196078,0.125490196078,0.133333333333,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('CHANGE COLORS').layer('CHANGE COLOR').content('CHANGE COLOR HERE').color;"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":90,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.408,"y":1},"o":{"x":0.518,"y":0},"t":32,"s":[104,65,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.396,"y":1},"o":{"x":0.461,"y":0},"t":56,"s":[59,65,0],"to":[0,0,0],"ti":[0,0,0]},{"t":80,"s":[69,65,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"rc","d":1,"s":{"a":0,"k":[20,20],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":3,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.125490196078,0.125490196078,0.133333333333,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('CHANGE COLORS').layer('CHANGE COLOR').content('CHANGE COLOR HERE').color;"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":90,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":30,"s":[0]},{"t":54,"s":[-90]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.408,"y":1},"o":{"x":0.518,"y":0},"t":30,"s":[69,65,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.396,"y":1},"o":{"x":0.461,"y":0},"t":54,"s":[24,65,0],"to":[0,0,0],"ti":[0,0,0]},{"t":78,"s":[34,65,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"rc","d":1,"s":{"a":0,"k":[20,20],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":3,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.125490196078,0.125490196078,0.133333333333,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('CHANGE COLORS').layer('CHANGE COLOR').content('CHANGE COLOR HERE').color;"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":90,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0493aa..fa8587c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,15 +1,21 @@ - ECC Wallet - Your Shielded Address + + Done + Cancel + + View History + Enter an amount to send - a178e1ef062133fc121079cb12fa43c7 Enter a shielded Zcash address Enter an amount to send Your transaction is shielded and your address is not available to the recipient Your transaction is shielded but your address will be sent to the recipient via the memo + Cancel + Retry + See Details Any details you\'d like to share? @@ -19,4 +25,9 @@ My balance was . . . I\'d like . . . + + ECC Wallet + a178e1ef062133fc121079cb12fa43c7 + Your Shielded Address + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 116bd2b..9d3d816 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -40,6 +40,10 @@ @color/zcashWhite + + - +