[#142] Send ZEC scaffold (#253)

Implements a basic scaffold for sending ZEC.

Some new model objects and an extension method were added that I hope to move to the SDK.

Followup issues include
 - Press-and-hold for the Send button #249
 - Prevent illegal input in input form #218
 - Prevent undefined behavior if locale changes #217
 - Add error handling to the Zec send screen #250
 - Add confirmation after send is created #252
This commit is contained in:
Carter Jernigan 2022-03-01 08:11:23 -05:00 committed by GitHub
parent 842fcd1574
commit 3d4f9fb4b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1003 additions and 39 deletions

View File

@ -23,6 +23,8 @@ import androidx.test.rule.GrantPermissionRule
import androidx.test.runner.screenshot.Screenshot
import cash.z.ecc.app.test.EccScreenCaptureProcessor
import cash.z.ecc.sdk.fixture.SeedPhraseFixture
import cash.z.ecc.sdk.fixture.WalletAddressFixture
import cash.z.ecc.sdk.model.MonetarySeparators
import cash.z.ecc.ui.MainActivity
import cash.z.ecc.ui.R
import cash.z.ecc.ui.screen.backup.BackupTag
@ -158,20 +160,33 @@ class ScreenshotTest {
it.assertExists()
it.performClick()
}
// Back to home screen
// Back to home
composeTestRule.onNode(hasContentDescription(getStringResource(R.string.settings_back_content_description))).also {
it.assertExists()
it.performClick()
}
composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.secretState.value is SecretState.Ready }
composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.walletSnapshot.value != null }
composeTestRule.onNode(hasText(getStringResource(R.string.home_button_request))).also {
it.assertExists()
it.performClick()
}
composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.walletSnapshot.value != null }
requestZecScreenshots(composeTestRule)
navigateTo(MainActivity.NAV_HOME)
composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.secretState.value is SecretState.Ready }
composeTestRule.onNode(hasText(getStringResource(R.string.home_button_send))).also {
it.assertExists()
it.performClick()
}
composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.synchronizer.value != null }
composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.spendingKey.value != null }
composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.walletSnapshot.value != null }
sendZecScreenshots(composeTestRule)
navigateTo(MainActivity.NAV_HOME)
}
}
@ -350,3 +365,29 @@ private fun requestZecScreenshots(composeTestRule: ComposeTestRule) {
ScreenshotTest.takeScreenshot("Request 1")
}
private fun sendZecScreenshots(composeTestRule: ComposeTestRule) {
composeTestRule.onNode(hasText(getStringResource(R.string.send_title))).also {
it.assertExists()
}
ScreenshotTest.takeScreenshot("Send 1")
composeTestRule.onNodeWithText(getStringResource(R.string.send_amount)).also {
val separators = MonetarySeparators.current()
it.performTextInput("{${separators.decimal}}123")
}
composeTestRule.onNodeWithText(getStringResource(R.string.send_to)).also {
it.performTextInput(WalletAddressFixture.UNIFIED_ADDRESS_STRING)
}
composeTestRule.onNodeWithText(getStringResource(R.string.send_create)).also {
it.performClick()
}
composeTestRule.waitForIdle()
ScreenshotTest.takeScreenshot("Send 2")
}

View File

@ -5,13 +5,14 @@ _Note: This document will continue to be updated as the app is implemented._
* Versions are declared in [gradle.properties](../gradle.properties). There's still enough inconsistency in how versions are handled in Gradle, that this is as close as we can get to a universal system. A version catalog is used for dependencies and is configured in [settings.gradle.kts](../settings.gradle.kts), but other versions like Gradle Plug-ins, the NDK version, Java version, and Android SDK versions don't fit into the version catalog model and are read directly from the properties
* Much of the Gradle configuration lives in [build-conventions](../build-conventions/) to prevent repetitive configuration as additional modules are added to the project
* Build scripts are written in Kotlin, so that a single language is used across build and the app code bases
* Only Gradle, Google, and JetBrains plug-ins are included in the critical path. Third party plug-ins can be used, but they're outside the critical path. For example, the Gradle Versions Plugin could be removed and wouldn't negative impact building, testing, or deploying the app
* Only Gradle, Google, and JetBrains plug-ins are included in the critical path. Third party plug-ins can be used, but they're outside the critical path. For example, the Gradle Versions Plugin could be removed and wouldn't negatively impact local building, testing, or releasing the app
* Repository restrictions are enabled in [build-conventions](../build-conventions/settings.gradle.kts), [settings.gradle.kts](../settings.gradle.kts), and [build.gradle.kts](../build.gradle.kts) to reduce likelihood of pulling in an incorrect dependency. If adding a new dependency, these restrictions may need to be changed otherwise an error that the dependency cannot be found will be displayed
# Multiplatform
While this repository is for an Android application, efforts are made to give multiplatform flexibility in the future. Specific adaptions that are being made:
* Where possible, common code is extracted into multiplatform modules
* In UI state management code, Kotlin Flow is often preferred over Android LiveData and Compose State to grant future flexibility
* Saver is preferred over @Parcelize for objects in the SDK
Note: test coverage for multiplatform modules behaves differently than coverage for Android modules. Coverage is only generated for a JVM target, and requires running two tasks in sequence: `./gradlew check -PIS_COVERAGE_ENABLED=true; ./gradlew jacocoTestReport -PIS_COVERAGE_ENABLED=true`
@ -20,7 +21,7 @@ The main entrypoints of the application are:
* [AppImpl.kt](../app/src/main/java/cash/z/ecc/app/AppImpl.kt) - The root Application object defined in the app module
* [MainActivity.kt](../ui-lib/src/main/java/cash/z/ecc/ui/MainActivity.kt) - The main Activity, defined in ui-lib. Note that the Activity is NOT exported. Instead, the app module defines an activity-alias in the AndroidManifest which is what presents the actual icon on the Android home screen.
## Modules
# Modules
The logical components of the app are implemented as a number of Gradle modules.
* `app` — Compiles all of the modules together into the final application. This module contains minimal actual code. Note that the Java package structure for this module is under `cash.z.ecc.app` while the Android package name is `cash.z.ecc`.
@ -34,7 +35,10 @@ The logical components of the app are implemented as a number of Gradle modules.
* `sdk-ext-lib` — Contains extensions on top of the to the Zcash SDK. Some of these extensions might be migrated into the SDK eventually, while others might represent Android-centric idioms. Depending on how this module evolves, it could adopt another name such as `wallet-lib` or be split into two.
* `spackle-lib` — Random utilities, to fill in the cracks in the Kotlin and Android frameworks.
## Shared Resources
# Test Fixtures
Until the Kotlin adopts support for fixtures, fixtures live within the main source modules. These fixtures make it easy to write automated tests, as well as create Compose previews. Although these fixtures are compiled into the main application, they should be removed by R8 in release builds.
# Shared Resources
There are some app-wide resources that share a common namespace, and these should be documented here to make it easy to ensure there are no collisions.
* SharedPreferences

View File

@ -17,8 +17,8 @@ dependencies {
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.zcash.sdk)
implementation(libs.zcash.bip39)
api(libs.zcash.sdk)
api(libs.zcash.bip39)
androidTestImplementation(libs.bundles.androidx.test)
androidTestImplementation(libs.kotlinx.coroutines.test)

View File

@ -0,0 +1,21 @@
package cash.z.ecc.sdk.ext
import kotlin.test.Test
import kotlin.test.assertEquals
class StringExtTest {
@Test
fun sizeInBytes_empty() {
assertEquals(0, "".sizeInUtf8Bytes())
}
@Test
fun sizeInBytes_one() {
assertEquals(1, "a".sizeInUtf8Bytes())
}
@Test
fun sizeInBytes_unicode() {
assertEquals(2, "á".sizeInUtf8Bytes())
}
}

View File

@ -0,0 +1,30 @@
package cash.z.ecc.sdk.model
import cash.z.ecc.sdk.fixture.ZecSendFixture
import java.lang.IllegalArgumentException
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MemoTest {
companion object {
private const val BYTE_STRING_513 = """
asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfa
"""
}
@Test
fun isWithinMaxSize_too_big() {
assertFalse(Memo.isWithinMaxLength(BYTE_STRING_513))
}
@Test
fun isWithinMaxSize_ok() {
assertTrue(Memo.isWithinMaxLength(ZecSendFixture.MEMO.value))
}
@Test(IllegalArgumentException::class)
fun init_max_size() {
Memo(BYTE_STRING_513)
}
}

View File

@ -0,0 +1,18 @@
package cash.z.ecc.sdk.model
import cash.z.ecc.sdk.fixture.WalletAddressFixture
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class WalletAddressTest {
@Test
@ExperimentalCoroutinesApi
fun unified_equals_different_instance() = runTest {
val one = WalletAddressFixture.unified()
val two = WalletAddressFixture.unified()
assertEquals(one, two)
}
}

View File

@ -0,0 +1,11 @@
package cash.z.ecc.sdk
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.sdk.model.ZecSend
fun Synchronizer.send(spendingKey: String, send: ZecSend) = sendToAddress(
spendingKey,
send.amount.value,
send.destination.address,
send.memo.value
)

View File

@ -0,0 +1,7 @@
package cash.z.ecc.sdk.ext
import java.nio.charset.Charset
private val UTF_8 = Charset.forName("UTF-8")
fun String.sizeInUtf8Bytes() = toByteArray(UTF_8).size

View File

@ -0,0 +1,9 @@
package cash.z.ecc.sdk.fixture
import cash.z.ecc.sdk.model.Memo
object MemoFixture {
const val MEMO_STRING = "Thanks for lunch"
fun new(memoString: String = MEMO_STRING) = Memo(memoString)
}

View File

@ -0,0 +1,10 @@
package cash.z.ecc.sdk.fixture
import cash.z.ecc.sdk.model.Zatoshi
object ZatoshiFixture {
@Suppress("MagicNumber")
const val ZATOSHI_LONG = 123456789L
fun new(value: Long = ZATOSHI_LONG) = Zatoshi(value)
}

View File

@ -0,0 +1,20 @@
package cash.z.ecc.sdk.fixture
import cash.z.ecc.sdk.model.Memo
import cash.z.ecc.sdk.model.WalletAddress
import cash.z.ecc.sdk.model.Zatoshi
import cash.z.ecc.sdk.model.ZecSend
object ZecSendFixture {
const val ADDRESS: String = WalletAddressFixture.UNIFIED_ADDRESS_STRING
@Suppress("MagicNumber")
val AMOUNT = Zatoshi(123)
val MEMO = MemoFixture.new()
suspend fun new(
address: String = ADDRESS,
amount: Zatoshi = AMOUNT,
message: Memo = MEMO
) = ZecSend(WalletAddress.Unified.new(address), amount, message)
}

View File

@ -0,0 +1,24 @@
package cash.z.ecc.sdk.model
import cash.z.ecc.sdk.ext.sizeInUtf8Bytes
@JvmInline
value class Memo(val value: String) {
init {
require(isWithinMaxLength(value)) {
"Memo length in bytes must be less than $MAX_MEMO_LENGTH_BYTES but " +
"actually has length ${value.sizeInUtf8Bytes()}"
}
}
companion object {
/**
* The decoded memo contents MUST NOT exceed 512 bytes.
*
* https://zips.z.cash/zip-0321
*/
private const val MAX_MEMO_LENGTH_BYTES = 512
fun isWithinMaxLength(memoString: String) = memoString.sizeInUtf8Bytes() <= MAX_MEMO_LENGTH_BYTES
}
}

View File

@ -4,9 +4,9 @@ package cash.z.ecc.sdk.model
*
*/
@JvmInline
value class Zatoshi(val amount: Long) {
value class Zatoshi(val value: Long) {
init {
require(amount >= 0)
require(value >= 0)
}
companion object

View File

@ -21,9 +21,9 @@ data class ZecRequest(val address: WalletAddress.Unified, val amount: Zatoshi, v
}
@JvmInline
value class ZecRequestMessage(val message: String) {
value class ZecRequestMessage(val value: String) {
init {
require(message.length <= MAX_MESSAGE_LENGTH)
require(value.length <= MAX_MESSAGE_LENGTH)
}
companion object {

View File

@ -0,0 +1,47 @@
package cash.z.ecc.sdk.model
import kotlinx.coroutines.runBlocking
data class ZecSend(val destination: WalletAddress, val amount: Zatoshi, val memo: Memo) {
companion object
}
fun ZecSend.Companion.new(
destinationString: String,
zecString: String,
memoString: String,
monetarySeparators: MonetarySeparators
): ZecSendValidation {
// This runBlocking shouldn't have a performance impact, since everything needs to be loaded at this point.
// However it would be better to eliminate it entirely.
val destination = runBlocking { WalletAddress.Unified.new(destinationString) }
val amount = Zatoshi.fromZecString(zecString, monetarySeparators)
val memo = Memo(memoString)
val validationErrors = buildSet {
if (null == amount) {
add(ZecSendValidation.Invalid.ValidationError.INVALID_AMOUNT)
}
// TODO [#250]: Implement all validation
// TODO [#342]: https://github.com/zcash/zcash-android-wallet-sdk/issues/342
}
return if (validationErrors.isEmpty()) {
ZecSendValidation.Valid(ZecSend(destination, amount!!, memo))
} else {
ZecSendValidation.Invalid(validationErrors)
}
}
sealed class ZecSendValidation {
data class Valid(val zecSend: ZecSend) : ZecSendValidation()
data class Invalid(val validationErrors: Set<ValidationError>) : ZecSendValidation() {
enum class ValidationError {
INVALID_ADDRESS,
INVALID_AMOUNT,
INVALID_MEMO
}
}
}

View File

@ -52,7 +52,7 @@ private const val DECIMALS = 8
// TODO [#412]: https://github.com/zcash/zcash-android-wallet-sdk/issues/412
// The SDK needs to fix the API for currency conversion
fun Zatoshi.toZecString() = amount.convertZatoshiToZecString(DECIMALS, DECIMALS)
fun Zatoshi.toZecString() = value.convertZatoshiToZecString(DECIMALS, DECIMALS)
/*
* ZEC is our own currency, so there's not going to be an existing localization that matches it perfectly.

View File

@ -34,6 +34,7 @@ android {
"src/main/res/ui/restore",
"src/main/res/ui/request",
"src/main/res/ui/seed",
"src/main/res/ui/send",
"src/main/res/ui/settings",
"src/main/res/ui/wallet_address"
)

View File

@ -21,6 +21,7 @@ import cash.z.ecc.ui.theme.ZcashTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
class BackupViewTest {
@get:Rule
@ -209,18 +210,18 @@ class BackupViewTest {
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initalStage: BackupStage) {
private val state = BackupState(initalStage)
private var onCopyToClipboardCount = 0
private val onCopyToClipboardCount = AtomicInteger(0)
private var onCompleteCallbackCount = 0
private val onCompleteCallbackCount = AtomicInteger(0)
fun getOnCopyToClipboardCount(): Int {
composeTestRule.waitForIdle()
return onCopyToClipboardCount
return onCopyToClipboardCount.get()
}
fun getOnCompleteCallbackCount(): Int {
composeTestRule.waitForIdle()
return onCompleteCallbackCount
return onCompleteCallbackCount.get()
}
fun getStage(): BackupStage {
@ -235,8 +236,8 @@ class BackupViewTest {
PersistableWalletFixture.new(),
state,
TestChoices(),
onCopyToClipboard = { onCopyToClipboardCount++ },
onComplete = { onCompleteCallbackCount++ }
onCopyToClipboard = { onCopyToClipboardCount.incrementAndGet() },
onComplete = { onCompleteCallbackCount.incrementAndGet() }
)
}
}

View File

@ -15,6 +15,7 @@ import cash.z.ecc.ui.theme.ZcashTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
class OnboardingViewTest {
@get:Rule
@ -243,17 +244,17 @@ class OnboardingViewTest {
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initalStage: OnboardingStage) {
private val onboardingState = OnboardingState(initalStage)
private var onCreateWalletCallbackCount = 0
private var onImportWalletCallbackCount = 0
private val onCreateWalletCallbackCount = AtomicInteger(0)
private val onImportWalletCallbackCount = AtomicInteger(0)
fun getOnCreateWalletCallbackCount(): Int {
composeTestRule.waitForIdle()
return onCreateWalletCallbackCount
return onCreateWalletCallbackCount.get()
}
fun getOnImportWalletCallbackCount(): Int {
composeTestRule.waitForIdle()
return onImportWalletCallbackCount
return onImportWalletCallbackCount.get()
}
fun getOnboardingStage(): OnboardingStage {
@ -266,8 +267,8 @@ class OnboardingViewTest {
ZcashTheme {
Onboarding(
onboardingState,
onCreateWallet = { onCreateWalletCallbackCount++ },
onImportWallet = { onImportWalletCallbackCount++ }
onCreateWallet = { onCreateWalletCallbackCount.incrementAndGet() },
onImportWallet = { onImportWalletCallbackCount.incrementAndGet() }
)
}
}

View File

@ -68,7 +68,7 @@ class RequestViewTest {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.address)
assertEquals(Zatoshi(12300000), it.amount)
assertTrue(it.message.message.isEmpty())
assertTrue(it.message.value.isEmpty())
}
}
@ -88,7 +88,7 @@ class RequestViewTest {
}
composeTestRule.onNodeWithText(getStringResource(R.string.request_message)).also {
it.performTextInput(ZecRequestFixture.MESSAGE.message)
it.performTextInput(ZecRequestFixture.MESSAGE.value)
}
composeTestRule.onNodeWithText(getStringResource(R.string.request_create)).also {
@ -101,7 +101,7 @@ class RequestViewTest {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.address)
assertEquals(Zatoshi(12300000), it.amount)
assertEquals(ZecRequestFixture.MESSAGE.message, it.message.message)
assertEquals(ZecRequestFixture.MESSAGE.value, it.message.value)
}
}
@ -159,7 +159,7 @@ class RequestViewTest {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.address)
assertEquals(Zatoshi(12300000), it.amount)
assertTrue(it.message.message.isEmpty())
assertTrue(it.message.value.isEmpty())
}
}

View File

@ -30,6 +30,7 @@ import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger
class RestoreViewTest {
@get:Rule
@ -194,9 +195,9 @@ class RestoreViewTest {
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initialState: List<String>) {
private val state = WordList(initialState)
private var onBackCount = 0
private val onBackCount = AtomicInteger(0)
private var onFinishedCount = 0
private val onFinishedCount = AtomicInteger(0)
fun getUserInputWords(): List<String> {
composeTestRule.waitForIdle()
@ -205,12 +206,12 @@ class RestoreViewTest {
fun getOnBackCount(): Int {
composeTestRule.waitForIdle()
return onBackCount
return onBackCount.get()
}
fun getOnFinishedCount(): Int {
composeTestRule.waitForIdle()
return onFinishedCount
return onFinishedCount.get()
}
init {
@ -220,11 +221,11 @@ class RestoreViewTest {
Mnemonics.getCachedWords(Locale.ENGLISH.language).toSortedSet(),
state,
onBack = {
onBackCount++
onBackCount.incrementAndGet()
},
paste = { "" },
onFinished = {
onFinishedCount++
onFinishedCount.incrementAndGet()
},
)
}

View File

@ -0,0 +1,24 @@
package cash.z.ecc.ui.screen.send.ext
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.SmallTest
import cash.z.ecc.sdk.fixture.WalletAddressFixture
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class WalletAddressExtTest {
@Test
@SmallTest
@OptIn(ExperimentalCoroutinesApi::class)
fun testAbbreviated() = runTest {
val actual = WalletAddressFixture.shieldedSapling().abbreviated(ApplicationProvider.getApplicationContext())
// TODO [#248]: The expected value should probably be reversed if the locale is RTL
val expected = "ztest…rxnwg"
assertEquals(expected, actual)
}
}

View File

@ -0,0 +1,36 @@
package cash.z.ecc.ui.screen.send.ext
import androidx.compose.runtime.saveable.SaverScope
import androidx.test.filters.SmallTest
import cash.z.ecc.sdk.fixture.ZecSendFixture
import cash.z.ecc.sdk.model.ZecSend
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class ZecSendExtTest {
@Test
@SmallTest
@OptIn(ExperimentalCoroutinesApi::class)
fun round_trip() = runTest {
val original = ZecSendFixture.new()
val saved = with(ZecSend.Saver) {
val allowingScope = SaverScope { true }
allowingScope.save(original)
}
val restored = ZecSend.Saver.restore(saved!!)
assertEquals(original, restored)
}
@Test
@SmallTest
fun restore_empty() {
val restored = ZecSend.Saver.restore(emptyList<Any?>())
assertEquals(null, restored)
}
}

View File

@ -0,0 +1,271 @@
package cash.z.ecc.ui.screen.send.view
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.filters.MediumTest
import cash.z.ecc.sdk.fixture.MemoFixture
import cash.z.ecc.sdk.fixture.WalletAddressFixture
import cash.z.ecc.sdk.fixture.ZatoshiFixture
import cash.z.ecc.sdk.fixture.ZecRequestFixture
import cash.z.ecc.sdk.model.Memo
import cash.z.ecc.sdk.model.MonetarySeparators
import cash.z.ecc.sdk.model.Zatoshi
import cash.z.ecc.sdk.model.ZecSend
import cash.z.ecc.ui.R
import cash.z.ecc.ui.test.getStringResource
import cash.z.ecc.ui.theme.ZcashTheme
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class SendViewTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
@MediumTest
fun create_button_disabled() {
@Suppress("UNUSED_VARIABLE")
val testSetup = TestSetup(composeTestRule)
composeTestRule.onNodeWithText(getStringResource(R.string.send_create)).also {
it.assertExists()
it.assertIsNotEnabled()
}
}
@Test
@MediumTest
@OptIn(ExperimentalCoroutinesApi::class)
fun create_request_no_memo() = runTest {
val testSetup = TestSetup(composeTestRule)
assertEquals(0, testSetup.getOnCreateCount())
assertEquals(null, testSetup.getLastSend())
composeTestRule.setValidAmount()
composeTestRule.setValidAddress()
composeTestRule.clickCreateAndSend()
composeTestRule.assertOnConfirmation()
composeTestRule.clickConfirmation()
assertEquals(1, testSetup.getOnCreateCount())
testSetup.getLastSend().also {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.destination)
assertEquals(Zatoshi(12300000), it.amount)
assertTrue(it.memo.value.isEmpty())
}
}
@Test
@MediumTest
@OptIn(ExperimentalCoroutinesApi::class)
fun create_request_with_memo() = runTest {
val testSetup = TestSetup(composeTestRule)
assertEquals(0, testSetup.getOnCreateCount())
assertEquals(null, testSetup.getLastSend())
composeTestRule.setValidAmount()
composeTestRule.setValidAddress()
composeTestRule.onNodeWithText(getStringResource(R.string.send_memo)).also {
it.performTextInput(MemoFixture.MEMO_STRING)
}
composeTestRule.clickCreateAndSend()
composeTestRule.assertOnConfirmation()
composeTestRule.clickConfirmation()
assertEquals(1, testSetup.getOnCreateCount())
testSetup.getLastSend().also {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.destination)
assertEquals(Zatoshi(12300000), it.amount)
assertEquals(ZecRequestFixture.MESSAGE.value, it.memo.value)
}
}
@Test
@MediumTest
@Ignore("https://github.com/zcash/secant-android-wallet/issues/218")
fun create_request_illegal_amount() {
val testSetup = TestSetup(composeTestRule)
assertEquals(0, testSetup.getOnCreateCount())
assertEquals(null, testSetup.getLastSend())
composeTestRule.onNodeWithText(getStringResource(R.string.send_amount)).also {
val separators = MonetarySeparators.current()
it.performTextInput("{${separators.decimal}}1{${separators.decimal}}2{${separators.decimal}}3{${separators.decimal}}4")
}
composeTestRule.setValidAddress()
composeTestRule.clickCreateAndSend()
assertEquals(0, testSetup.getOnCreateCount())
}
@Test
@MediumTest
@OptIn(ExperimentalCoroutinesApi::class)
fun max_memo_length() = runTest {
val testSetup = TestSetup(composeTestRule)
composeTestRule.setValidAmount()
composeTestRule.setValidAddress()
val input = buildString {
while (Memo.isWithinMaxLength(toString())) {
append("a")
}
}
composeTestRule.onNodeWithText(getStringResource(R.string.send_memo)).also {
it.performTextInput(input)
}
composeTestRule.clickCreateAndSend()
composeTestRule.assertOnConfirmation()
composeTestRule.clickConfirmation()
assertEquals(1, testSetup.getOnCreateCount())
testSetup.getLastSend().also {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.destination)
assertEquals(Zatoshi(12300000), it.amount)
assertTrue(it.memo.value.isEmpty())
}
}
@Test
@MediumTest
fun back_on_form() {
val testSetup = TestSetup(composeTestRule)
assertEquals(0, testSetup.getOnBackCount())
composeTestRule.clickBack()
assertEquals(1, testSetup.getOnBackCount())
}
@Test
@MediumTest
fun back_on_confirmation() {
val testSetup = TestSetup(composeTestRule)
assertEquals(0, testSetup.getOnBackCount())
composeTestRule.setValidAmount()
composeTestRule.setValidAddress()
composeTestRule.clickCreateAndSend()
composeTestRule.assertOnConfirmation()
composeTestRule.clickBack()
composeTestRule.assertOnForm()
assertEquals(0, testSetup.getOnBackCount())
}
private class TestSetup(private val composeTestRule: ComposeContentTestRule) {
private val onBackCount = AtomicInteger(0)
private val onCreateCount = AtomicInteger(0)
@Volatile
private var onSendZecRequest: ZecSend? = null
fun getOnBackCount(): Int {
composeTestRule.waitForIdle()
return onBackCount.get()
}
fun getOnCreateCount(): Int {
composeTestRule.waitForIdle()
return onCreateCount.get()
}
fun getLastSend(): ZecSend? {
composeTestRule.waitForIdle()
return onSendZecRequest
}
init {
composeTestRule.setContent {
ZcashTheme {
Send(
mySpendableBalance = ZatoshiFixture.new(),
goBack = {
onBackCount.incrementAndGet()
},
onCreateAndSend = {
onCreateCount.incrementAndGet()
onSendZecRequest = it
}
)
}
}
}
}
}
private fun ComposeContentTestRule.clickBack() {
onNodeWithContentDescription(getStringResource(R.string.send_back_content_description)).also {
it.performClick()
}
}
private fun ComposeContentTestRule.setValidAmount() {
onNodeWithText(getStringResource(R.string.send_amount)).also {
val separators = MonetarySeparators.current()
it.performTextInput("{${separators.decimal}}123")
}
}
private fun ComposeContentTestRule.setValidAddress() {
onNodeWithText(getStringResource(R.string.send_to)).also {
it.performTextInput(WalletAddressFixture.UNIFIED_ADDRESS_STRING)
}
}
private fun ComposeContentTestRule.clickCreateAndSend() {
onNodeWithText(getStringResource(R.string.send_create)).also {
it.performClick()
}
}
private fun ComposeContentTestRule.clickConfirmation() {
onNodeWithText(getStringResource(R.string.send_confirm)).also {
it.performClick()
}
}
private fun ComposeContentTestRule.assertOnForm() {
onNodeWithText(getStringResource(R.string.send_create)).also {
it.assertExists()
}
}
private fun ComposeContentTestRule.assertOnConfirmation() {
onNodeWithText(getStringResource(R.string.send_confirm)).also {
it.assertExists()
}
}

View File

@ -16,6 +16,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
@OptIn(ExperimentalCoroutinesApi::class)
class WalletAddressViewTest {
@ -154,11 +155,11 @@ class WalletAddressViewTest {
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initialState: WalletAddresses) {
private var onBackCount = 0
private val onBackCount = AtomicInteger(0)
fun getOnBackCount(): Int {
composeTestRule.waitForIdle()
return onBackCount
return onBackCount.get()
}
init {
@ -167,7 +168,7 @@ class WalletAddressViewTest {
WalletAddresses(
initialState,
onBack = {
onBackCount++
onBackCount.incrementAndGet()
}
)
}

View File

@ -26,11 +26,13 @@ import cash.z.ecc.android.sdk.type.ZcashNetwork
import cash.z.ecc.sdk.model.PersistableWallet
import cash.z.ecc.sdk.model.SeedPhrase
import cash.z.ecc.sdk.model.ZecRequest
import cash.z.ecc.sdk.send
import cash.z.ecc.sdk.type.fromResources
import cash.z.ecc.ui.design.compat.FontCompat
import cash.z.ecc.ui.design.component.GradientSurface
import cash.z.ecc.ui.screen.backup.view.BackupWallet
import cash.z.ecc.ui.screen.backup.viewmodel.BackupViewModel
import cash.z.ecc.ui.screen.home.model.spendableBalance
import cash.z.ecc.ui.screen.home.view.Home
import cash.z.ecc.ui.screen.home.viewmodel.SecretState
import cash.z.ecc.ui.screen.home.viewmodel.WalletViewModel
@ -42,6 +44,7 @@ import cash.z.ecc.ui.screen.restore.view.RestoreWallet
import cash.z.ecc.ui.screen.restore.viewmodel.CompleteWordSetState
import cash.z.ecc.ui.screen.restore.viewmodel.RestoreViewModel
import cash.z.ecc.ui.screen.seed.view.Seed
import cash.z.ecc.ui.screen.send.view.Send
import cash.z.ecc.ui.screen.settings.view.Settings
import cash.z.ecc.ui.screen.wallet_address.view.WalletAddresses
import cash.z.ecc.ui.theme.ZcashTheme
@ -212,7 +215,7 @@ class MainActivity : ComponentActivity() {
WrapHome(
goScan = {},
goProfile = { navController.navigate(NAV_PROFILE) },
goSend = {},
goSend = { navController.navigate(NAV_SEND) },
goRequest = { navController.navigate(NAV_REQUEST) }
)
}
@ -253,6 +256,9 @@ class MainActivity : ComponentActivity() {
composable(NAV_REQUEST) {
WrapRequest(goBack = { navController.popBackStack() })
}
composable(NAV_SEND) {
WrapSend(goBack = { navController.popBackStack() })
}
}
}
@ -396,6 +402,28 @@ class MainActivity : ComponentActivity() {
}
}
@Composable
private fun WrapSend(
goBack: () -> Unit
) {
val synchronizer = walletViewModel.synchronizer.collectAsState().value
val spendableBalance = walletViewModel.walletSnapshot.collectAsState().value?.spendableBalance()
val spendingKey = walletViewModel.spendingKey.collectAsState().value
if (null == synchronizer || null == spendableBalance || null == spendingKey) {
// Display loading indicator
} else {
Send(
mySpendableBalance = spendableBalance,
goBack = goBack,
onCreateAndSend = {
synchronizer.send(spendingKey, it)
goBack()
},
)
}
}
companion object {
@VisibleForTesting
internal val SPLASH_SCREEN_DELAY = 0.seconds
@ -417,6 +445,9 @@ class MainActivity : ComponentActivity() {
@VisibleForTesting
const val NAV_REQUEST = "request"
@VisibleForTesting
const val NAV_SEND = "send"
}
}

View File

@ -27,3 +27,12 @@ fun WalletSnapshot.totalBalance(): Zatoshi {
return Zatoshi(total.coerceAtLeast(0))
}
fun WalletSnapshot.spendableBalance(): Zatoshi {
// Note that considering both to be spendable is subject to change.
// The user experience could be confusing, and in the future we might prefer to ask users
// to transfer their balance to the latest balance type to make it spendable.
val total = (orchardBalance + saplingBalance).totalZatoshi
return Zatoshi(total.coerceAtLeast(0))
}

View File

@ -3,12 +3,15 @@ package cash.z.ecc.ui.screen.home.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toSeed
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.db.entity.PendingTransaction
import cash.z.ecc.android.sdk.db.entity.Transaction
import cash.z.ecc.android.sdk.db.entity.isMined
import cash.z.ecc.android.sdk.db.entity.isSubmitSuccess
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.type.WalletBalance
import cash.z.ecc.global.WalletCoordinator
import cash.z.ecc.sdk.model.PersistableWallet
@ -20,6 +23,7 @@ import cash.z.ecc.ui.preference.StandardPreferenceKeys
import cash.z.ecc.ui.preference.StandardPreferenceSingleton
import cash.z.ecc.ui.screen.home.model.WalletSnapshot
import cash.z.ecc.work.WorkIds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -35,6 +39,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
// To make this more multiplatform compatible, we need to remove the dependency on Context
// for loading the preferences.
@ -79,6 +84,22 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
SecretState.Loading
)
// This needs to be refactored once we support pin lock
val spendingKey = secretState
.filterIsInstance<SecretState.Ready>()
.map { it.persistableWallet }
.map {
val bip39Seed = withContext(Dispatchers.IO) {
Mnemonics.MnemonicCode(it.seedPhrase.joinToString()).toSeed()
}
DerivationTool.deriveSpendingKeys(bip39Seed, it.network)[0]
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
null
)
@OptIn(FlowPreview::class)
val walletSnapshot: StateFlow<WalletSnapshot?> = synchronizer
.filterNotNull()

View File

@ -0,0 +1,30 @@
package cash.z.ecc.ui.screen.send.ext
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import cash.z.ecc.sdk.model.WalletAddress
import cash.z.ecc.ui.R
/**
* How far into the address will be abbreviation look forwards and backwards.
*/
internal const val ABBREVIATION_INDEX = 5
@Composable
@ReadOnlyComposable
internal fun WalletAddress.abbreviated(): String {
LocalConfiguration.current
return abbreviated(LocalContext.current)
}
internal fun WalletAddress.abbreviated(context: Context): String {
require(address.length >= ABBREVIATION_INDEX) { "Address must be at least 5 characters long" }
val firstFive = address.substring(0, ABBREVIATION_INDEX)
val lastFive = address.substring(address.length - ABBREVIATION_INDEX, address.length)
return context.getString(R.string.send_abbreviated_address_format, firstFive, lastFive)
}

View File

@ -0,0 +1,39 @@
package cash.z.ecc.ui.screen.send.ext
import androidx.compose.runtime.saveable.mapSaver
import cash.z.ecc.sdk.model.Memo
import cash.z.ecc.sdk.model.WalletAddress
import cash.z.ecc.sdk.model.Zatoshi
import cash.z.ecc.sdk.model.ZecSend
import kotlinx.coroutines.runBlocking
private const val KEY_ADDRESS = "address"
private const val KEY_AMOUNT = "amount"
private const val KEY_MEMO = "memo"
// Using a custom saver instead of Parcelize, to avoid adding an Android-specific API to
// the ZecSend class
internal val ZecSend.Companion.Saver
get() = run {
mapSaver<ZecSend?>(
save = {
it?.toSaverMap() ?: emptyMap()
},
restore = {
if (it.isEmpty()) {
null
} else {
val address = runBlocking { WalletAddress.Unified.new(it[KEY_ADDRESS] as String) }
val amount = Zatoshi(it[KEY_AMOUNT] as Long)
val memo = Memo(it[KEY_MEMO] as String)
ZecSend(address, amount, memo)
}
}
)
}
private fun ZecSend.toSaverMap() = buildMap {
put(KEY_ADDRESS, destination.address)
put(KEY_AMOUNT, amount.value)
put(KEY_MEMO, memo.value)
}

View File

@ -0,0 +1,6 @@
package cash.z.ecc.ui.screen.send.model
enum class SendStage {
Form,
Confirmation
}

View File

@ -0,0 +1,236 @@
package cash.z.ecc.ui.screen.send.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import cash.z.ecc.sdk.fixture.ZatoshiFixture
import cash.z.ecc.sdk.model.Memo
import cash.z.ecc.sdk.model.MonetarySeparators
import cash.z.ecc.sdk.model.Zatoshi
import cash.z.ecc.sdk.model.ZecSend
import cash.z.ecc.sdk.model.ZecSendValidation
import cash.z.ecc.sdk.model.ZecString
import cash.z.ecc.sdk.model.new
import cash.z.ecc.sdk.model.toZecString
import cash.z.ecc.ui.R
import cash.z.ecc.ui.design.MINIMAL_WEIGHT
import cash.z.ecc.ui.design.component.GradientSurface
import cash.z.ecc.ui.design.component.PrimaryButton
import cash.z.ecc.ui.design.component.TextField
import cash.z.ecc.ui.screen.send.ext.ABBREVIATION_INDEX
import cash.z.ecc.ui.screen.send.ext.Saver
import cash.z.ecc.ui.screen.send.ext.abbreviated
import cash.z.ecc.ui.screen.send.model.SendStage
import cash.z.ecc.ui.theme.ZcashTheme
@Composable
@Preview
fun PreviewSend() {
ZcashTheme(darkTheme = true) {
GradientSurface {
Send(
mySpendableBalance = ZatoshiFixture.new(),
goBack = {},
onCreateAndSend = {}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Send(
mySpendableBalance: Zatoshi,
goBack: () -> Unit,
onCreateAndSend: (ZecSend) -> Unit,
) {
// For now, we're avoiding sub-navigation to keep the navigation logic simple. But this might
// change once deep-linking support is added. It depends on whether deep linking should do one of:
// 1. Use a different UI flow entirely
// 2. Show a pre-filled Send form
// 3. Go directly to the press-and-hold confirmation
val (sendStage, setSendStage) = rememberSaveable { mutableStateOf(SendStage.Form) }
Scaffold(topBar = {
SendTopAppBar(onBack = {
when (sendStage) {
SendStage.Form -> goBack()
SendStage.Confirmation -> setSendStage(SendStage.Form)
}
})
}) {
SendMainContent(
mySpendableBalance,
sendStage,
setSendStage,
onCreateAndSend = onCreateAndSend
)
}
}
@Composable
private fun SendTopAppBar(onBack: () -> Unit) {
SmallTopAppBar(
title = { Text(text = stringResource(id = R.string.send_title)) },
navigationIcon = {
IconButton(
onClick = onBack
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.send_back_content_description)
)
}
}
)
}
@Composable
private fun SendMainContent(
myBalance: Zatoshi,
sendStage: SendStage,
setSendStage: (SendStage) -> Unit,
onCreateAndSend: (ZecSend) -> Unit
) {
val (zecSend, setZecSend) = rememberSaveable(stateSaver = ZecSend.Saver) { mutableStateOf(null) }
if (sendStage == SendStage.Form || null == zecSend) {
SendForm(
myBalance = myBalance,
previousZecSend = zecSend,
onCreateAndSend = {
setSendStage(SendStage.Confirmation)
setZecSend(it)
}
)
} else {
Confirmation(zecSend) {
onCreateAndSend(zecSend)
}
}
}
// TODO [#217]: Need to handle changing of Locale after user input, but before submitting the button.
@Composable
private fun SendForm(
myBalance: Zatoshi,
previousZecSend: ZecSend?,
onCreateAndSend: (ZecSend) -> Unit
) {
val monetarySeparators = MonetarySeparators.current()
val allowedCharacters = ZecString.allowedCharacters(monetarySeparators)
var amountZecString by rememberSaveable {
mutableStateOf(previousZecSend?.amount?.toZecString() ?: "")
}
var recipientAddressString by rememberSaveable {
mutableStateOf(previousZecSend?.destination?.address ?: "")
}
var memoString by rememberSaveable { mutableStateOf(previousZecSend?.memo?.value ?: "") }
var validation by rememberSaveable { mutableStateOf<Set<ZecSendValidation.Invalid.ValidationError>>(emptySet()) }
Column(Modifier.fillMaxHeight()) {
Row(Modifier.fillMaxWidth()) {
Text(text = myBalance.toZecString())
}
TextField(
value = amountZecString,
onValueChange = { newValue ->
// TODO [#218]: this doesn't prevent illegal input. So users could still type `1.2.3.4`
amountZecString = newValue.filter { allowedCharacters.contains(it) }
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
label = { Text(stringResource(id = R.string.send_amount)) }
)
Spacer(Modifier.size(8.dp))
TextField(
value = recipientAddressString,
onValueChange = { recipientAddressString = it },
label = { Text(stringResource(id = R.string.send_to)) }
)
Spacer(Modifier.size(8.dp))
TextField(value = memoString, onValueChange = {
if (Memo.isWithinMaxLength(it)) {
memoString = it
}
}, label = { Text(stringResource(id = R.string.send_memo)) })
Spacer(Modifier.fillMaxHeight(MINIMAL_WEIGHT))
if (validation.isNotEmpty()) {
/*
* Note: this is not localized in that it uses the enum constant name and joins the string
* without regard for RTL. This will get resolved once we do proper validation for
* the fields.
*/
Text(validation.joinToString(", "))
}
PrimaryButton(
onClick = {
val zecSendValidation = ZecSend.new(
recipientAddressString,
amountZecString,
memoString,
monetarySeparators
)
when (zecSendValidation) {
is ZecSendValidation.Valid -> onCreateAndSend(zecSendValidation.zecSend)
is ZecSendValidation.Invalid -> validation = zecSendValidation.validationErrors
}
},
text = stringResource(id = R.string.send_create),
// Check for ABBREVIATION_INDEX goes away once proper address validation is in place.
// For now, it just prevents a crash on the confirmation screen.
enabled = amountZecString.isNotBlank() && recipientAddressString.length > ABBREVIATION_INDEX
)
}
}
@Composable
private fun Confirmation(zecSend: ZecSend, onConfirmation: () -> Unit) {
Column {
Text(
stringResource(
R.string.send_amount_and_address_format,
zecSend.amount.toZecString(),
zecSend.destination.abbreviated()
)
)
// TODO [#249]: Implement press-and-hold
Button(onClick = onConfirmation) {
Text(text = stringResource(id = R.string.send_confirm))
}
}
}

View File

@ -0,0 +1,14 @@
<resources xmlns:tools="http://schemas.android.com/tools" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="send_title">Send ZEC</string>
<string name="send_back_content_description">Back</string>
<string name="send_to">Who would you like to send ZEC to?</string>
<string name="send_amount">How much?</string>
<string name="send_memo">Memo</string>
<string name="send_create">Send</string>
<string name="send_amount_and_address_format" formatted="true">Send <xliff:g id="amount" example="12.345">%1$s</xliff:g> ZEC to <xliff:g id="address" example="zs1g7cqw … mvyzgm">%2$s</xliff:g>?</string>
<string name="send_abbreviated_address_format" formatted="true"><xliff:g id="first_five" example="zs1g7">%1$s</xliff:g><xliff:g id="last_five" example="mvyzg">%2$s</xliff:g></string>
<string name="send_confirm">Press and hold to send ZEC</string>
</resources>