[#631] Initial Compose scaffolding for demo app

* [#631] Initial Compose scaffolding for demo app

* Bump Compose Compiler

- To bypass Kotlin version incompatibility

* Switch ConfigureSeed from Column to Scaffold

- To unify our screens UI components
- And to have a fullscreen content, and thus avoid another background color out of column (e.g. in system dark mode)

* Split addresses to two rows

- To reduce a risk of a user overlooks other addresses scrolled out of the screen

* Additional code migrated from Secant

- Added just missing test files, fixtures and model classes
- Fix previously broken dependencies imports

* Link issue to code

Co-authored-by: Honza <rychnovsky.honza@gmail.com>
This commit is contained in:
Carter Jernigan 2022-12-23 05:00:37 -05:00 committed by GitHub
parent ee388448be
commit a4a6b25bfb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
101 changed files with 4149 additions and 127 deletions

View File

@ -18,10 +18,16 @@ android {
multiDexEnabled = true
vectorDrawables.useSupportLibrary = true
}
buildFeatures {
compose = true
viewBinding = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.androidx.compose.compiler.get().versionConstraint.displayName
}
val releaseKeystorePath = project.property("ZCASH_RELEASE_KEYSTORE_PATH").toString()
val releaseKeystorePassword = project.property("ZCASH_RELEASE_KEYSTORE_PASSWORD").toString()
val releaseKeyAlias = project.property("ZCASH_RELEASE_KEY_ALIAS").toString()
@ -104,19 +110,27 @@ dependencies {
implementation(libs.bip39)
// Android
implementation(libs.androidx.core)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.core)
implementation(libs.androidx.multidex)
implementation(libs.androidx.navigation.fragment)
implementation(libs.androidx.navigation.ui)
implementation(libs.androidx.security.crypto)
implementation(libs.bundles.androidx.compose.core)
implementation(libs.bundles.androidx.compose.extended)
implementation(libs.material)
// Just to support profile installation and tracing events needed by benchmark tests
implementation(libs.androidx.profileinstaller)
implementation(libs.androidx.tracing)
implementation(libs.material)
androidTestImplementation(libs.bundles.androidx.test)
androidTestImplementation(libs.kotlin.reflect)
androidTestImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.kotlin.test)
implementation(libs.bundles.grpc)
implementation(libs.kotlinx.datetime)
}
fladle {

View File

@ -0,0 +1,10 @@
@file:Suppress("ktlint:filename")
package cash.z.wallet.sdk.sample.demoapp
fun <T> Iterator<T>.count(): Int {
var count = 0
forEach { count++ }
return count
}

View File

@ -2,7 +2,7 @@ package cash.z.wallet.sdk.sample.demoapp
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.demoapp.type.fromResources
import cash.z.ecc.android.sdk.ext.convertZecToZatoshi
import cash.z.ecc.android.sdk.ext.toHex
import cash.z.ecc.android.sdk.internal.TroubleshootingTwig
@ -17,7 +17,6 @@ import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.defaultForNetwork
import cash.z.ecc.android.sdk.model.isFailure
import cash.z.ecc.android.sdk.tool.DerivationTool
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse

View File

@ -0,0 +1,22 @@
package cash.z.wallet.sdk.sample.demoapp.ext
import cash.z.ecc.android.sdk.demoapp.ext.sizeInUtf8Bytes
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,18 @@
package cash.z.wallet.sdk.sample.demoapp.fixture
import cash.z.ecc.android.sdk.demoapp.model.CurrencyConversion
import cash.z.ecc.android.sdk.demoapp.model.FiatCurrency
import kotlinx.datetime.Instant
import kotlinx.datetime.toInstant
object CurrencyConversionFixture {
val FIAT_CURRENCY = FiatCurrencyFixture.new()
val TIMESTAMP = "2022-07-08T11:51:44Z".toInstant()
const val PRICE_OF_ZEC = 54.98
fun new(
fiatCurrency: FiatCurrency = FIAT_CURRENCY,
timestamp: Instant = TIMESTAMP,
priceOfZec: Double = PRICE_OF_ZEC
) = CurrencyConversion(fiatCurrency, timestamp, priceOfZec)
}

View File

@ -0,0 +1,9 @@
package cash.z.wallet.sdk.sample.demoapp.fixture
import cash.z.ecc.android.sdk.demoapp.model.FiatCurrency
object FiatCurrencyFixture {
const val USD = "USD"
fun new(code: String = USD) = FiatCurrency(code)
}

View File

@ -0,0 +1,15 @@
package cash.z.wallet.sdk.sample.demoapp.fixture
import cash.z.ecc.android.sdk.demoapp.model.Locale
object LocaleFixture {
const val LANGUAGE = "en"
const val COUNTRY = "US"
val VARIANT: String? = null
fun new(
language: String = LANGUAGE,
country: String? = COUNTRY,
variant: String? = VARIANT
) = Locale(language, country, variant)
}

View File

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

View File

@ -0,0 +1,13 @@
package cash.z.wallet.sdk.sample.demoapp.fixture
import cash.z.ecc.android.sdk.demoapp.model.MonetarySeparators
object MonetarySeparatorsFixture {
const val US_GROUPING_SEPARATOR = ','
const val US_DECIMAL_SEPARATOR = '.'
fun new(
grouping: Char = US_GROUPING_SEPARATOR,
decimal: Char = US_DECIMAL_SEPARATOR
) = MonetarySeparators(grouping, decimal)
}

View File

@ -0,0 +1,23 @@
package cash.z.wallet.sdk.sample.demoapp.fixture
import cash.z.ecc.android.sdk.demoapp.model.PersistableWallet
import cash.z.ecc.android.sdk.demoapp.model.SeedPhrase
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
object PersistableWalletFixture {
val NETWORK = ZcashNetwork.Testnet
// These came from the mainnet 1500000.json file
@Suppress("MagicNumber")
val BIRTHDAY = BlockHeight.new(ZcashNetwork.Mainnet, 1500000L)
val SEED_PHRASE = SeedPhraseFixture.new()
fun new(
network: ZcashNetwork = NETWORK,
birthday: BlockHeight = BIRTHDAY,
seedPhrase: SeedPhrase = SEED_PHRASE
) = PersistableWallet(network, birthday, seedPhrase)
}

View File

@ -0,0 +1,10 @@
package cash.z.wallet.sdk.sample.demoapp.fixture
import cash.z.ecc.android.sdk.demoapp.model.SeedPhrase
object SeedPhraseFixture {
@Suppress("MaxLineLength")
val SEED_PHRASE = "still champion voice habit trend flight survey between bitter process artefact blind carbon truly provide dizzy crush flush breeze blouse charge solid fish spread"
fun new(seedPhrase: String = SEED_PHRASE) = SeedPhrase.new(seedPhrase)
}

View File

@ -0,0 +1,19 @@
package cash.z.wallet.sdk.sample.demoapp.fixture
import cash.z.ecc.android.sdk.demoapp.model.WalletAddress
object WalletAddressFixture {
// These fixture values are derived from the secret defined in PersistableWalletFixture
// TODO [#161]: Pending SDK support
// TODO [#161]: https://github.com/zcash/secant-android-wallet/issues/161
const val UNIFIED_ADDRESS_STRING = "Unified GitHub Issue #161"
@Suppress("MaxLineLength")
const val SAPLING_ADDRESS_STRING = "zs1hf72k87gev2qnvg9228vn2xt97adfelju2hm2ap4xwrxkau5dz56mvkeseer3u8283wmy7skt4u"
const val TRANSPARENT_ADDRESS_STRING = "t1QZMTZaU1EwXppCLL5dR6U9y2M4ph3CSPK"
suspend fun unified() = WalletAddress.Unified.new(UNIFIED_ADDRESS_STRING)
suspend fun sapling() = WalletAddress.Sapling.new(SAPLING_ADDRESS_STRING)
suspend fun transparent() = WalletAddress.Transparent.new(TRANSPARENT_ADDRESS_STRING)
}

View File

@ -0,0 +1,17 @@
package cash.z.wallet.sdk.sample.demoapp.fixture
import cash.z.ecc.android.sdk.demoapp.model.WalletAddress
import cash.z.ecc.android.sdk.demoapp.model.WalletAddresses
object WalletAddressesFixture {
suspend fun new(
unified: String = WalletAddressFixture.UNIFIED_ADDRESS_STRING,
sapling: String = WalletAddressFixture.SAPLING_ADDRESS_STRING,
transparent: String = WalletAddressFixture.TRANSPARENT_ADDRESS_STRING
) = WalletAddresses(
WalletAddress.Unified.new(unified),
WalletAddress.Sapling.new(sapling),
WalletAddress.Transparent.new(transparent)
)
}

View File

@ -0,0 +1,10 @@
package cash.z.wallet.sdk.sample.demoapp.fixture
import cash.z.ecc.android.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,21 @@
package cash.z.wallet.sdk.sample.demoapp.fixture
import cash.z.ecc.android.sdk.demoapp.fixture.WalletAddressFixture
import cash.z.ecc.android.sdk.demoapp.model.Memo
import cash.z.ecc.android.sdk.demoapp.model.WalletAddress
import cash.z.ecc.android.sdk.demoapp.model.ZecSend
import cash.z.ecc.android.sdk.model.Zatoshi
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,110 @@
package cash.z.wallet.sdk.sample.demoapp.model
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.demoapp.ext.ui.toFiatCurrencyState
import cash.z.ecc.android.sdk.demoapp.model.FiatCurrencyConversionRateState
import cash.z.wallet.sdk.sample.demoapp.fixture.CurrencyConversionFixture
import cash.z.wallet.sdk.sample.demoapp.fixture.LocaleFixture
import cash.z.wallet.sdk.sample.demoapp.fixture.MonetarySeparatorsFixture
import cash.z.wallet.sdk.sample.demoapp.fixture.ZatoshiFixture
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import org.junit.Test
import kotlin.test.assertIs
import kotlin.time.Duration.Companion.seconds
class FiatCurrencyConversionRateStateTest {
@Test
@SmallTest
fun future_near() {
val zatoshi = ZatoshiFixture.new()
val frozenClock = FrozenClock(
CurrencyConversionFixture.TIMESTAMP - FiatCurrencyConversionRateState.FUTURE_CUTOFF_AGE_INCLUSIVE
)
val currencyConversion = CurrencyConversionFixture.new()
val result = zatoshi.toFiatCurrencyState(currencyConversion, LocaleFixture.new(), MonetarySeparatorsFixture.new(), frozenClock)
assertIs<FiatCurrencyConversionRateState.Current>(result)
}
@Test
@SmallTest
fun future_far() {
val zatoshi = ZatoshiFixture.new()
val frozenClock = FrozenClock(
CurrencyConversionFixture.TIMESTAMP - FiatCurrencyConversionRateState.FUTURE_CUTOFF_AGE_INCLUSIVE - 1.seconds
)
val currencyConversion = CurrencyConversionFixture.new()
val result = zatoshi.toFiatCurrencyState(currencyConversion, LocaleFixture.new(), MonetarySeparatorsFixture.new(), frozenClock)
assertIs<FiatCurrencyConversionRateState.Unavailable>(result)
}
@Test
@SmallTest
fun current() {
val zatoshi = ZatoshiFixture.new()
val frozenClock = FrozenClock(CurrencyConversionFixture.TIMESTAMP)
val currencyConversion = CurrencyConversionFixture.new(
timestamp = CurrencyConversionFixture.TIMESTAMP - 1.seconds
)
val result = zatoshi.toFiatCurrencyState(currencyConversion, LocaleFixture.new(), MonetarySeparatorsFixture.new(), frozenClock)
assertIs<FiatCurrencyConversionRateState.Current>(result)
}
@Test
@SmallTest
fun stale() {
val zatoshi = ZatoshiFixture.new()
val frozenClock = FrozenClock(CurrencyConversionFixture.TIMESTAMP)
val currencyConversion = CurrencyConversionFixture.new(
timestamp = CurrencyConversionFixture.TIMESTAMP - FiatCurrencyConversionRateState.CURRENT_CUTOFF_AGE_INCLUSIVE - 1.seconds
)
val result = zatoshi.toFiatCurrencyState(currencyConversion, LocaleFixture.new(), MonetarySeparatorsFixture.new(), frozenClock)
assertIs<FiatCurrencyConversionRateState.Stale>(result)
}
@Test
@SmallTest
fun too_stale() {
val zatoshi = ZatoshiFixture.new()
val frozenClock = FrozenClock(CurrencyConversionFixture.TIMESTAMP)
val currencyConversion = CurrencyConversionFixture.new(
timestamp = CurrencyConversionFixture.TIMESTAMP - FiatCurrencyConversionRateState.STALE_CUTOFF_AGE_INCLUSIVE - 1.seconds
)
val result = zatoshi.toFiatCurrencyState(currencyConversion, LocaleFixture.new(), MonetarySeparatorsFixture.new(), frozenClock)
assertIs<FiatCurrencyConversionRateState.Unavailable>(result)
}
@Test
@SmallTest
fun null_conversion_rate() {
val zatoshi = ZatoshiFixture.new()
val result = zatoshi.toFiatCurrencyState(null, LocaleFixture.new(), MonetarySeparatorsFixture.new())
assertIs<FiatCurrencyConversionRateState.Unavailable>(result)
}
}
private class FrozenClock(private val timestamp: Instant) : Clock {
override fun now() = timestamp
}

View File

@ -0,0 +1,29 @@
package cash.z.wallet.sdk.sample.demoapp.model
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.demoapp.model.Locale
import cash.z.ecc.android.sdk.demoapp.model.toJavaLocale
import cash.z.ecc.android.sdk.demoapp.model.toKotlinLocale
import org.junit.Test
import kotlin.test.assertEquals
class LocaleTest {
@Test
@SmallTest
fun toKotlinLocale() {
val javaLocale = java.util.Locale.forLanguageTag("en-US")
val kotlinLocale = javaLocale.toKotlinLocale()
assertEquals("en", kotlinLocale.language)
assertEquals("US", kotlinLocale.region)
assertEquals(null, kotlinLocale.variant)
}
@Test
@SmallTest
fun toJavaLocale() {
val kotlinLocale = Locale("en", "US", null)
val javaLocale = kotlinLocale.toJavaLocale()
assertEquals("en-US", javaLocale.toLanguageTag())
}
}

View File

@ -0,0 +1,30 @@
package cash.z.wallet.sdk.sample.demoapp.model
import cash.z.ecc.android.sdk.demoapp.model.Memo
import cash.z.wallet.sdk.sample.demoapp.fixture.ZecSendFixture
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,20 @@
package cash.z.wallet.sdk.sample.demoapp.model
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.demoapp.model.PercentDecimal
import org.junit.Test
class PercentDecimalTest {
@Test(expected = IllegalArgumentException::class)
@SmallTest
fun require_greater_than_zero() {
PercentDecimal(-1.0f)
}
@Test(expected = IllegalArgumentException::class)
@SmallTest
fun require_less_than_one() {
PercentDecimal(1.5f)
}
}

View File

@ -0,0 +1,52 @@
package cash.z.wallet.sdk.sample.demoapp.model
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.demoapp.model.PersistableWallet
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.wallet.sdk.sample.demoapp.count
import cash.z.wallet.sdk.sample.demoapp.fixture.PersistableWalletFixture
import cash.z.wallet.sdk.sample.demoapp.fixture.SeedPhraseFixture
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PersistableWalletTest {
@Test
@SmallTest
fun serialize() {
val persistableWallet = PersistableWalletFixture.new()
val jsonObject = persistableWallet.toJson()
assertEquals(4, jsonObject.keys().count())
assertTrue(jsonObject.has(PersistableWallet.KEY_VERSION))
assertTrue(jsonObject.has(PersistableWallet.KEY_NETWORK_ID))
assertTrue(jsonObject.has(PersistableWallet.KEY_SEED_PHRASE))
assertTrue(jsonObject.has(PersistableWallet.KEY_BIRTHDAY))
assertEquals(1, jsonObject.getInt(PersistableWallet.KEY_VERSION))
assertEquals(ZcashNetwork.Testnet.id, jsonObject.getInt(PersistableWallet.KEY_NETWORK_ID))
assertEquals(PersistableWalletFixture.SEED_PHRASE.joinToString(), jsonObject.getString(PersistableWallet.KEY_SEED_PHRASE))
// Birthday serialization is tested in a separate file
}
@Test
@SmallTest
fun round_trip() {
val persistableWallet = PersistableWalletFixture.new()
val deserialized = PersistableWallet.from(persistableWallet.toJson())
assertEquals(persistableWallet, deserialized)
assertFalse(persistableWallet === deserialized)
}
@Test
@SmallTest
fun toString_security() {
val actual = PersistableWalletFixture.new().toString()
assertFalse(actual.contains(SeedPhraseFixture.SEED_PHRASE))
}
}

View File

@ -0,0 +1,27 @@
package cash.z.wallet.sdk.sample.demoapp.model
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.demoapp.model.SeedPhrase
import cash.z.wallet.sdk.sample.demoapp.fixture.SeedPhraseFixture
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class SeedPhraseTest {
@Test
@SmallTest
fun split_and_join() {
val seedPhrase = SeedPhrase.new(SeedPhraseFixture.SEED_PHRASE)
assertEquals(SeedPhraseFixture.SEED_PHRASE, seedPhrase.joinToString())
}
@Test
@SmallTest
fun security() {
val seedPhrase = SeedPhraseFixture.new()
seedPhrase.split.forEach {
assertFalse(seedPhrase.toString().contains(it))
}
}
}

View File

@ -0,0 +1,18 @@
package cash.z.wallet.sdk.sample.demoapp.model
import cash.z.wallet.sdk.sample.demoapp.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,21 @@
package cash.z.wallet.sdk.sample.demoapp.model
import androidx.test.filters.SmallTest
import cash.z.wallet.sdk.sample.demoapp.fixture.WalletAddressesFixture
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class WalletAddressesTest {
@Test
@SmallTest
fun security() = runTest {
val walletAddresses = WalletAddressesFixture.new()
val actual = WalletAddressesFixture.new().toString()
assertFalse(actual.contains(walletAddresses.sapling.address))
assertFalse(actual.contains(walletAddresses.transparent.address))
assertFalse(actual.contains(walletAddresses.unified.address))
}
}

View File

@ -0,0 +1,78 @@
package cash.z.wallet.sdk.sample.demoapp.model
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.demoapp.ext.ui.toFiatString
import cash.z.ecc.android.sdk.demoapp.model.MonetarySeparators
import cash.z.wallet.sdk.sample.demoapp.fixture.CurrencyConversionFixture
import cash.z.wallet.sdk.sample.demoapp.fixture.LocaleFixture
import cash.z.wallet.sdk.sample.demoapp.fixture.MonetarySeparatorsFixture
import cash.z.wallet.sdk.sample.demoapp.fixture.ZatoshiFixture
import org.junit.Test
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class ZatoshiExtTest {
companion object {
private val EN_US_SEPARATORS = MonetarySeparatorsFixture.new()
private val CURRENCY_CONVERSION = CurrencyConversionFixture.new()
}
@Test
@SmallTest
fun zero_zatoshi_to_fiat_conversion_test() {
val zatoshi = ZatoshiFixture.new(0L)
val fiatString = zatoshi.toFiatString(CURRENCY_CONVERSION, LocaleFixture.new(), EN_US_SEPARATORS)
fiatString.also {
assertNotNull(it)
assertTrue(it.isNotEmpty())
assertTrue(it.contains("0"))
assertTrue(it.isValidNumber(EN_US_SEPARATORS))
}
}
@Test
@SmallTest
fun regular_zatoshi_to_fiat_conversion_test() {
val zatoshi = ZatoshiFixture.new(123_456_789L)
val fiatString = zatoshi.toFiatString(CURRENCY_CONVERSION, LocaleFixture.new(), EN_US_SEPARATORS)
fiatString.also {
assertNotNull(it)
assertTrue(it.isNotEmpty())
assertTrue(it.isValidNumber(EN_US_SEPARATORS))
}
}
@Test
@SmallTest
fun rounded_zatoshi_to_fiat_conversion_test() {
val roundedZatoshi = ZatoshiFixture.new(100_000_000L)
val roundedCurrencyConversion = CurrencyConversionFixture.new(
priceOfZec = 100.0
)
val fiatString = roundedZatoshi.toFiatString(
roundedCurrencyConversion,
LocaleFixture.new(),
EN_US_SEPARATORS
)
fiatString.also {
assertNotNull(it)
assertTrue(it.isNotEmpty())
assertTrue(it.isValidNumber(EN_US_SEPARATORS))
assertTrue("$100${EN_US_SEPARATORS.decimal}00" == it)
}
}
}
private fun Char.isDigitOrSeparator(separators: MonetarySeparators): Boolean {
return this.isDigit() || this == separators.decimal || this == separators.grouping
}
private fun String.isValidNumber(separators: MonetarySeparators): Boolean {
return this
.drop(1) // remove currency symbol
.all { return it.isDigitOrSeparator(separators) }
}

View File

@ -0,0 +1,169 @@
package cash.z.wallet.sdk.sample.demoapp.model
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.demoapp.R
import cash.z.ecc.android.sdk.demoapp.model.ZecStringExt
import cash.z.wallet.sdk.sample.demoapp.fixture.MonetarySeparatorsFixture
import cash.z.wallet.sdk.sample.demoapp.ui.common.getStringResourceWithArgs
import org.junit.Test
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class ZecStringExtTest {
companion object {
private val EN_US_SEPARATORS = MonetarySeparatorsFixture.new()
}
private fun getContinuousRegex(): Regex {
return getStringResourceWithArgs(
R.string.co_electriccoin_zcash_zec_amount_regex_continuous_filter,
arrayOf(
EN_US_SEPARATORS.grouping,
EN_US_SEPARATORS.decimal
)
).toRegex()
}
private fun getConfirmRegex(): Regex {
return getStringResourceWithArgs(
R.string.co_electriccoin_zcash_zec_amount_regex_confirm_filter,
arrayOf(
EN_US_SEPARATORS.grouping,
EN_US_SEPARATORS.decimal
)
).toRegex()
}
@Test
@SmallTest
fun check_continuous_regex_validity() {
val regexString = getStringResourceWithArgs(
R.string.co_electriccoin_zcash_zec_amount_regex_continuous_filter,
arrayOf(
EN_US_SEPARATORS.grouping,
EN_US_SEPARATORS.decimal
)
)
assertNotNull(regexString)
val regexAmountChecker = regexString.toRegex()
regexAmountChecker.also {
assertNotNull(regexAmountChecker)
assertTrue(regexAmountChecker.pattern.isNotEmpty())
}
}
@Test
@SmallTest
fun check_confirm_regex_validity() {
val regexString = getStringResourceWithArgs(
R.string.co_electriccoin_zcash_zec_amount_regex_confirm_filter,
arrayOf(
EN_US_SEPARATORS.grouping,
EN_US_SEPARATORS.decimal
)
)
assertNotNull(regexString)
val regexAmountChecker = regexString.toRegex()
regexAmountChecker.also {
assertNotNull(regexAmountChecker)
assertTrue(regexAmountChecker.pattern.isNotEmpty())
}
}
@Test
@SmallTest
fun check_continuous_regex_functionality_valid_inputs() {
getContinuousRegex().also {
assertTrue(it.matches(""))
assertTrue(it.matches("123"))
assertTrue(it.matches("${EN_US_SEPARATORS.decimal}"))
assertTrue(it.matches("${EN_US_SEPARATORS.decimal}123"))
assertTrue(it.matches("123${EN_US_SEPARATORS.grouping}"))
assertTrue(it.matches("123${EN_US_SEPARATORS.grouping}456"))
assertTrue(it.matches("123${EN_US_SEPARATORS.decimal}"))
assertTrue(it.matches("123${EN_US_SEPARATORS.decimal}456"))
assertTrue(it.matches("123${EN_US_SEPARATORS.grouping}456${EN_US_SEPARATORS.decimal}"))
assertTrue(it.matches("123${EN_US_SEPARATORS.grouping}456${EN_US_SEPARATORS.decimal}789"))
assertTrue(it.matches("1${EN_US_SEPARATORS.grouping}234${EN_US_SEPARATORS.grouping}567${EN_US_SEPARATORS.decimal}00"))
}
}
@Test
@SmallTest
fun check_continuous_regex_functionality_invalid_inputs() {
getContinuousRegex().also {
assertFalse(it.matches("aaa"))
assertFalse(it.matches("123aaa"))
assertFalse(it.matches("${EN_US_SEPARATORS.grouping}"))
assertFalse(it.matches("${EN_US_SEPARATORS.grouping}123"))
assertFalse(it.matches("123${EN_US_SEPARATORS.grouping}${EN_US_SEPARATORS.grouping}"))
assertFalse(it.matches("123${EN_US_SEPARATORS.decimal}${EN_US_SEPARATORS.decimal}"))
assertFalse(it.matches("1${EN_US_SEPARATORS.grouping}2${EN_US_SEPARATORS.grouping}3"))
assertFalse(it.matches("1${EN_US_SEPARATORS.decimal}2${EN_US_SEPARATORS.decimal}3"))
assertFalse(it.matches("1${EN_US_SEPARATORS.decimal}2${EN_US_SEPARATORS.grouping}3"))
}
}
@Test
@SmallTest
fun check_confirm_regex_functionality_valid_inputs() {
getConfirmRegex().also {
assertTrue(it.matches("123"))
assertTrue(it.matches(".123"))
assertTrue(it.matches("1,234"))
assertTrue(it.matches("1,234,567,890"))
assertTrue(it.matches("1.2"))
assertTrue(it.matches("123.4"))
assertTrue(it.matches("1.234"))
assertTrue(it.matches("1,123."))
assertTrue(it.matches("1,234.567"))
assertTrue(it.matches("1,234,567.890"))
}
}
@Test
@SmallTest
fun check_confirm_regex_functionality_invalid_inputs() {
getContinuousRegex().also {
assertFalse(it.matches("+@#$~^&*="))
assertFalse(it.matches("asdf"))
assertFalse(it.matches(".."))
assertFalse(it.matches(","))
assertFalse(it.matches(",,"))
assertFalse(it.matches(",."))
assertFalse(it.matches(".,"))
assertFalse(it.matches(",123"))
assertFalse(it.matches("1,2,3"))
assertFalse(it.matches("1.2,3,4"))
assertFalse(it.matches("123,,456"))
assertFalse(it.matches("123..456"))
assertFalse(it.matches("1.234,567"))
assertFalse(it.matches("1.234,567,890"))
}
}
@Test
@SmallTest
fun check_digits_between_grouping_separators_valid_test() {
assertTrue(ZecStringExt.checkFor3Digits(EN_US_SEPARATORS, "123"))
assertTrue(ZecStringExt.checkFor3Digits(EN_US_SEPARATORS, "1${EN_US_SEPARATORS.grouping}234"))
assertTrue(ZecStringExt.checkFor3Digits(EN_US_SEPARATORS, "1${EN_US_SEPARATORS.grouping}234${EN_US_SEPARATORS.grouping}"))
assertTrue(ZecStringExt.checkFor3Digits(EN_US_SEPARATORS, "1${EN_US_SEPARATORS.grouping}234${EN_US_SEPARATORS.grouping}5"))
assertTrue(ZecStringExt.checkFor3Digits(EN_US_SEPARATORS, "1${EN_US_SEPARATORS.grouping}234${EN_US_SEPARATORS.grouping}567${EN_US_SEPARATORS.grouping}8"))
}
@Test
@SmallTest
fun check_digits_between_grouping_separators_invalid_test() {
assertFalse(ZecStringExt.checkFor3Digits(EN_US_SEPARATORS, "1${EN_US_SEPARATORS.grouping}1${EN_US_SEPARATORS.grouping}2"))
assertFalse(ZecStringExt.checkFor3Digits(EN_US_SEPARATORS, "1${EN_US_SEPARATORS.grouping}12${EN_US_SEPARATORS.grouping}3"))
assertFalse(ZecStringExt.checkFor3Digits(EN_US_SEPARATORS, "1${EN_US_SEPARATORS.grouping}1234${EN_US_SEPARATORS.grouping}"))
assertFalse(ZecStringExt.checkFor3Digits(EN_US_SEPARATORS, "1${EN_US_SEPARATORS.grouping}123${EN_US_SEPARATORS.grouping}4${EN_US_SEPARATORS.grouping}"))
}
}

View File

@ -0,0 +1,102 @@
package cash.z.wallet.sdk.sample.demoapp.model
import android.content.Context
import android.content.res.Configuration
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.demoapp.model.fromZecString
import cash.z.ecc.android.sdk.demoapp.model.toZecString
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.wallet.sdk.sample.demoapp.fixture.MonetarySeparatorsFixture
import org.junit.Assert.assertEquals
import org.junit.Ignore
import org.junit.Test
import java.util.Locale
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class ZecStringTest {
companion object {
private val EN_US_MONETARY_SEPARATORS = MonetarySeparatorsFixture.new()
private val context = run {
val applicationContext = ApplicationProvider.getApplicationContext<Context>()
val enUsConfiguration = Configuration(applicationContext.resources.configuration).apply {
setLocale(Locale.US)
}
applicationContext.createConfigurationContext(enUsConfiguration)
}
}
@Test
fun empty_string() {
val actual = Zatoshi.fromZecString(context, "", EN_US_MONETARY_SEPARATORS)
val expected = null
assertEquals(expected, actual)
}
@Test
fun decimal_monetary_separator() {
val actual = Zatoshi.fromZecString(context, "1.13", EN_US_MONETARY_SEPARATORS)
val expected = Zatoshi(113000000L)
assertEquals(expected, actual)
}
@Test
fun comma_grouping_separator() {
val actual = Zatoshi.fromZecString(context, "1,130", EN_US_MONETARY_SEPARATORS)
val expected = Zatoshi(113000000000L)
assertEquals(expected, actual)
}
@Test
fun decimal_monetary_and() {
val actual = Zatoshi.fromZecString(context, "1,130", EN_US_MONETARY_SEPARATORS)
val expected = Zatoshi(113000000000L)
assertEquals(expected, actual)
}
@Test
@Ignore("https://github.com/zcash/zcash-android-wallet-sdk/issues/412")
fun toZecString() {
val expected = "1.13000000"
val actual = Zatoshi(113000000).toZecString()
assertEquals(expected, actual)
}
@Test
@Ignore("https://github.com/zcash/zcash-android-wallet-sdk/issues/412")
fun round_trip() {
val expected = Zatoshi(113000000L)
val actual = Zatoshi.fromZecString(context, expected.toZecString(), EN_US_MONETARY_SEPARATORS)
assertEquals(expected, actual)
}
@Test
fun parse_bad_string() {
assertNull(Zatoshi.fromZecString(context, "", EN_US_MONETARY_SEPARATORS))
assertNull(Zatoshi.fromZecString(context, "+@#$~^&*=", EN_US_MONETARY_SEPARATORS))
assertNull(Zatoshi.fromZecString(context, "asdf", EN_US_MONETARY_SEPARATORS))
}
@Test
fun parse_invalid_numbers() {
assertNull(Zatoshi.fromZecString(context, "", EN_US_MONETARY_SEPARATORS))
assertNull(Zatoshi.fromZecString(context, "1,2", EN_US_MONETARY_SEPARATORS))
assertNull(Zatoshi.fromZecString(context, "1,23,", EN_US_MONETARY_SEPARATORS))
assertNull(Zatoshi.fromZecString(context, "1,234,", EN_US_MONETARY_SEPARATORS))
}
@Test
@SmallTest
fun overflow_number_test() {
assertNotNull(Zatoshi.fromZecString(context, "21,000,000", EN_US_MONETARY_SEPARATORS))
assertNull(Zatoshi.fromZecString(context, "21,000,001", EN_US_MONETARY_SEPARATORS))
}
}

View File

@ -0,0 +1,28 @@
package cash.z.wallet.sdk.sample.demoapp.preference
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.demoapp.preference.EncryptedPreferenceKeys
import cash.z.ecc.android.sdk.demoapp.preference.PersistableWalletPreferenceDefault
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Test
import kotlin.reflect.full.memberProperties
class EncryptedPreferenceKeysTest {
// This test is primary to prevent copy-paste errors in preference keys
@SmallTest
@Test
fun key_values_unique() {
val fieldValueSet = mutableSetOf<String>()
EncryptedPreferenceKeys::class.memberProperties
.map { it.getter.call(EncryptedPreferenceKeys) }
.map { it as PersistableWalletPreferenceDefault }
.map { it.key }
.forEach {
assertThat("Duplicate key $it", fieldValueSet.contains(it.key), equalTo(false))
fieldValueSet.add(it.key)
}
}
}

View File

@ -0,0 +1,26 @@
package cash.z.wallet.sdk.sample.demoapp.preference
import cash.z.ecc.android.sdk.demoapp.preference.api.PreferenceProvider
import cash.z.ecc.android.sdk.demoapp.preference.model.entry.Key
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
/**
* @param mutableMapFactory Emits a new mutable map. Thread safety depends on the factory implementation.
*/
class MockPreferenceProvider(mutableMapFactory: () -> MutableMap<String, String?> = { mutableMapOf() }) :
PreferenceProvider {
private val map = mutableMapFactory()
override suspend fun getString(key: Key) = map[key.key]
// For the mock implementation, does not support observability of changes
override fun observe(key: Key): Flow<Unit> = flowOf(Unit)
override suspend fun hasKey(key: Key) = map.containsKey(key.key)
override suspend fun putString(key: Key, value: String?) {
map[key.key] = value
}
}

View File

@ -0,0 +1,10 @@
package cash.z.wallet.sdk.sample.demoapp.preference.fixture
import cash.z.ecc.android.sdk.demoapp.preference.model.entry.BooleanPreferenceDefault
import cash.z.ecc.android.sdk.demoapp.preference.model.entry.Key
object BooleanPreferenceDefaultFixture {
val KEY = Key("some_boolean_key") // $NON-NLS
fun newTrue() = BooleanPreferenceDefault(KEY, true)
fun newFalse() = BooleanPreferenceDefault(KEY, false)
}

View File

@ -0,0 +1,10 @@
package cash.z.wallet.sdk.sample.demoapp.preference.fixture
import cash.z.ecc.android.sdk.demoapp.preference.model.entry.IntegerPreferenceDefault
import cash.z.ecc.android.sdk.demoapp.preference.model.entry.Key
object IntegerPreferenceDefaultFixture {
val KEY = Key("some_string_key") // $NON-NLS
const val DEFAULT_VALUE = 123
fun new(key: Key = KEY, value: Int = DEFAULT_VALUE) = IntegerPreferenceDefault(key, value)
}

View File

@ -0,0 +1,10 @@
package cash.z.wallet.sdk.sample.demoapp.preference.fixture
import cash.z.ecc.android.sdk.demoapp.preference.model.entry.Key
import cash.z.ecc.android.sdk.demoapp.preference.model.entry.StringPreferenceDefault
object StringDefaultPreferenceFixture {
val KEY = Key("some_string_key") // $NON-NLS
const val DEFAULT_VALUE = "some_default_value" // $NON-NLS
fun new(key: Key = KEY, value: String = DEFAULT_VALUE) = StringPreferenceDefault(key, value)
}

View File

@ -0,0 +1,43 @@
package cash.z.wallet.sdk.sample.demoapp.preference.model.entry
import cash.z.wallet.sdk.sample.demoapp.preference.MockPreferenceProvider
import cash.z.wallet.sdk.sample.demoapp.preference.fixture.BooleanPreferenceDefaultFixture
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class BooleanPreferenceDefaultTest {
@Test
fun key() {
assertEquals(BooleanPreferenceDefaultFixture.KEY, BooleanPreferenceDefaultFixture.newTrue().key)
}
@Test
fun value_default_true() = runTest {
val entry = BooleanPreferenceDefaultFixture.newTrue()
assertTrue(entry.getValue(MockPreferenceProvider()))
}
@Test
fun value_default_false() = runTest {
val entry = BooleanPreferenceDefaultFixture.newFalse()
assertFalse(entry.getValue(MockPreferenceProvider()))
}
@Test
fun value_from_config_false() = runTest {
val entry = BooleanPreferenceDefaultFixture.newTrue()
val mockPreferenceProvider = MockPreferenceProvider { mutableMapOf(BooleanPreferenceDefaultFixture.KEY.key to false.toString()) }
assertFalse(entry.getValue(mockPreferenceProvider))
}
@Test
fun value_from_config_true() = runTest {
val entry = BooleanPreferenceDefaultFixture.newTrue()
val mockPreferenceProvider = MockPreferenceProvider { mutableMapOf(BooleanPreferenceDefaultFixture.KEY.key to true.toString()) }
assertTrue(entry.getValue(mockPreferenceProvider))
}
}

View File

@ -0,0 +1,32 @@
package cash.z.wallet.sdk.sample.demoapp.preference.model.entry
import cash.z.wallet.sdk.sample.demoapp.preference.MockPreferenceProvider
import cash.z.wallet.sdk.sample.demoapp.preference.fixture.IntegerPreferenceDefaultFixture
import cash.z.wallet.sdk.sample.demoapp.preference.fixture.StringDefaultPreferenceFixture
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class IntegerPreferenceDefaultTest {
@Test
fun key() {
assertEquals(IntegerPreferenceDefaultFixture.KEY, IntegerPreferenceDefaultFixture.new().key)
}
@Test
fun value_default() = runTest {
val entry = IntegerPreferenceDefaultFixture.new()
assertEquals(IntegerPreferenceDefaultFixture.DEFAULT_VALUE, entry.getValue(MockPreferenceProvider()))
}
@Test
fun value_override() = runTest {
val expected = IntegerPreferenceDefaultFixture.DEFAULT_VALUE + 5
val entry = IntegerPreferenceDefaultFixture.new()
val mockPreferenceProvider = MockPreferenceProvider { mutableMapOf(StringDefaultPreferenceFixture.KEY.key to expected.toString()) }
assertEquals(expected, entry.getValue(mockPreferenceProvider))
}
}

View File

@ -0,0 +1,30 @@
package cash.z.wallet.sdk.sample.demoapp.preference.model.entry
import cash.z.wallet.sdk.sample.demoapp.preference.MockPreferenceProvider
import cash.z.wallet.sdk.sample.demoapp.preference.fixture.StringDefaultPreferenceFixture
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class StringPreferenceDefaultTest {
@Test
fun key() {
assertEquals(StringDefaultPreferenceFixture.KEY, StringDefaultPreferenceFixture.new().key)
}
@Test
fun value_default() = runTest {
val entry = StringDefaultPreferenceFixture.new()
assertEquals(StringDefaultPreferenceFixture.DEFAULT_VALUE, entry.getValue(MockPreferenceProvider()))
}
@Test
fun value_override() = runTest {
val entry = StringDefaultPreferenceFixture.new()
val mockPreferenceProvider = MockPreferenceProvider { mutableMapOf(StringDefaultPreferenceFixture.KEY.key to "override") }
assertEquals("override", entry.getValue(mockPreferenceProvider))
}
}

View File

@ -0,0 +1,68 @@
package cash.z.wallet.sdk.sample.demoapp.ui.common
import androidx.test.filters.FlakyTest
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.demoapp.ui.common.throttle
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.ExperimentalTime
import kotlin.time.TimeMark
import kotlin.time.TimeSource
class FlowExtTest {
@OptIn(ExperimentalTime::class, ExperimentalCoroutinesApi::class)
@Test
@SmallTest
fun throttle_one_sec() = runTest {
val timer = TimeSource.Monotonic.markNow()
val flow = flow {
while (timer.elapsedNow() <= 5.seconds) {
emit(1)
}
}.throttle(1.seconds)
var timeMark: TimeMark? = null
flow.collect {
if (timeMark == null) {
timeMark = TimeSource.Monotonic.markNow()
} else {
assert(timeMark!!.elapsedNow() >= 1.seconds)
timeMark = TimeSource.Monotonic.markNow()
}
}
}
@OptIn(ExperimentalTime::class)
private fun raceConditionTest(duration: Duration): Boolean = runBlocking {
val flow = (0..1000).asFlow().throttle(duration)
val values = mutableListOf<Int>()
flow.collect {
values.add(it)
}
return@runBlocking values.zipWithNext().all { it.first <= it.second }
}
@FlakyTest
@Test
fun stressTest() = runBlocking {
for (i in 0..10) {
assertTrue { raceConditionTest(0.001.seconds) }
}
for (i in 0..10) {
assertTrue { raceConditionTest(0.0001.seconds) }
}
for (i in 0..10) {
assertTrue { raceConditionTest(0.00001.seconds) }
}
}
}

View File

@ -0,0 +1,14 @@
package cash.z.wallet.sdk.sample.demoapp.ui.common
import android.content.Context
import android.text.TextUtils
import android.view.View
import androidx.annotation.StringRes
import androidx.test.core.app.ApplicationProvider
import java.util.Locale
fun getStringResource(@StringRes resId: Int) = ApplicationProvider.getApplicationContext<Context>().getString(resId)
fun getStringResourceWithArgs(@StringRes resId: Int, formatArgs: Array<Any>) = ApplicationProvider.getApplicationContext<Context>().getString(resId, *formatArgs)
fun isLocaleRTL(locale: Locale) = TextUtils.getLayoutDirectionFromLocale(locale) == View.LAYOUT_DIRECTION_RTL

View File

@ -12,8 +12,9 @@
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:theme="@style/AppTheme.NoActionBar"
android:exported="true">
android:exported="true"
android:label="@string/app_name"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -21,6 +22,12 @@
</intent-filter>
</activity>
<activity
android:name=".ComposeActivity"
android:exported="false"
android:label="@string/app_name"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"/>
<!-- Enable profiling by benchmark -->
<profileable
android:shell="true"
@ -30,11 +37,10 @@
see https://issuetracker.google.com/issues/258619948 -->
<receiver
android:name="androidx.profileinstaller.ProfileInstallReceiver"
android:permission="android.permission.DUMP"
android:exported="true">
android:exported="true"
android:permission="android.permission.DUMP">
<intent-filter>
<action
android:name="androidx.profileinstaller.action.BENCHMARK_OPERATION" />
<action android:name="androidx.profileinstaller.action.BENCHMARK_OPERATION" />
</intent-filter>
</receiver>

View File

@ -1,18 +1,24 @@
package cash.z.ecc.android.sdk.demoapp
import androidx.multidex.MultiDexApplication
import cash.z.ecc.android.sdk.internal.TroubleshootingTwig
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.demoapp.util.Twig
class App : MultiDexApplication() {
override fun onCreate() {
super.onCreate()
Twig.initialize(applicationContext)
Twig.info { "Starting application…" }
if (BuildConfig.DEBUG) {
StrictModeHelper.enableStrictMode()
}
Twig.plant(TroubleshootingTwig())
// This is an internal API to the Zcash SDK to enable logging; it could change in the future
cash.z.ecc.android.sdk.internal.Twig.enabled(true)
} else {
// In release builds, logs should be stripped by R8 rules
Twig.assertLoggingStripped()
}
}
}

View File

@ -0,0 +1,52 @@
package cash.z.ecc.android.sdk.demoapp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.demoapp.type.fromResources
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel.SecretState
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel.WalletViewModel
import cash.z.ecc.android.sdk.demoapp.ui.screen.seed.view.Seed
import cash.z.ecc.android.sdk.model.ZcashNetwork
class ComposeActivity : ComponentActivity() {
private val walletViewModel by viewModels<WalletViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface {
MainContent()
}
}
}
}
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
private fun MainContent() {
when (walletViewModel.secretState.collectAsStateWithLifecycle().value) {
SecretState.Loading -> {
// In the future, we might consider displaying something different here.
}
SecretState.None -> {
Seed(
ZcashNetwork.fromResources(applicationContext),
onExistingWallet = { walletViewModel.persistExistingWallet(it) },
onNewWallet = { walletViewModel.persistNewWallet() }
)
}
is SecretState.Ready -> {
Navigation()
}
}
}
}

View File

@ -20,7 +20,7 @@ import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
import androidx.viewbinding.ViewBinding
import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.demoapp.type.fromResources
import cash.z.ecc.android.sdk.internal.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.internal.service.LightWalletService
import cash.z.ecc.android.sdk.internal.twig
@ -109,6 +109,9 @@ class MainActivity :
navController.navigate(R.id.nav_home)
sharedViewModel.resetSDK()
true
} else if (item.itemId == R.id.action_new_ui) {
startActivity(Intent(this, ComposeActivity::class.java))
true
} else {
super.onOptionsItemSelected(item)
}

View File

@ -0,0 +1,128 @@
package cash.z.ecc.android.sdk.demoapp
import android.annotation.TargetApi
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.NavOptionsBuilder
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import cash.z.ecc.android.sdk.demoapp.NavigationTargets.HOME
import cash.z.ecc.android.sdk.demoapp.NavigationTargets.SEND
import cash.z.ecc.android.sdk.demoapp.NavigationTargets.WALLET_ADDRESS_DETAILS
import cash.z.ecc.android.sdk.demoapp.ui.screen.addresses.view.Addresses
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.view.Home
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel.WalletViewModel
import cash.z.ecc.android.sdk.demoapp.ui.screen.send.view.Send
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
internal fun ComposeActivity.Navigation() {
val navController = rememberNavController()
val walletViewModel by viewModels<WalletViewModel>()
NavHost(navController = navController, startDestination = HOME) {
composable(HOME) {
val walletSnapshot = walletViewModel.walletSnapshot.collectAsStateWithLifecycle().value
if (null == walletSnapshot) {
// Display loading indicator
} else {
Home(
goSend = { navController.navigateJustOnce(SEND) },
goAddressDetails = { navController.navigateJustOnce(WALLET_ADDRESS_DETAILS) },
resetSdk = { walletViewModel.resetSdk() }
)
}
}
composable(WALLET_ADDRESS_DETAILS) {
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
if (null == synchronizer) {
// Display loading indicator
} else {
// I don't like giving synchronizer directly over to the view, but for now it isolates each of the
// demo app views
Addresses(
synchronizer = synchronizer,
copyToClipboard = { tag, textToCopy ->
copyToClipboard(applicationContext, tag, textToCopy)
},
onBack = { navController.popBackStackJustOnce(WALLET_ADDRESS_DETAILS) }
)
}
}
composable(SEND) {
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
val walletSnapshot = walletViewModel.walletSnapshot.collectAsStateWithLifecycle().value
val spendingKey = walletViewModel.spendingKey.collectAsStateWithLifecycle().value
if (null == synchronizer || null == walletSnapshot || null == spendingKey) {
// Display loading indicator
} else {
Send(
walletSnapshot = walletSnapshot,
onSend = {
// In the future, consider observing the flow and providing UI updates
walletViewModel.send(it)
navController.popBackStackJustOnce(SEND)
},
onBack = { navController.popBackStackJustOnce(SEND) }
)
}
}
}
}
private fun NavHostController.navigateJustOnce(
route: String,
navOptionsBuilder: (NavOptionsBuilder.() -> Unit)? = null
) {
if (currentDestination?.route == route) {
return
}
if (navOptionsBuilder != null) {
navigate(route, navOptionsBuilder)
} else {
navigate(route)
}
}
/**
* Pops up the current screen from the back stack. Parameter currentRouteToBePopped is meant to be
* set only to the current screen so we can easily debounce multiple screen popping from the back stack.
*
* @param currentRouteToBePopped current screen which should be popped up.
*/
private fun NavHostController.popBackStackJustOnce(currentRouteToBePopped: String) {
if (currentDestination?.route != currentRouteToBePopped) {
return
}
popBackStack()
}
// Note: this requires API level 23 (current min is 21 for the Demo-app). We should address this requirement, or set
// our Demo-app min to 23
@TargetApi(Build.VERSION_CODES.M)
fun copyToClipboard(context: Context, tag: String, textToCopy: String) {
val clipboardManager = context.getSystemService(ClipboardManager::class.java)
val data = ClipData.newPlainText(
tag,
textToCopy
)
clipboardManager.setPrimaryClip(data)
}
object NavigationTargets {
const val HOME = "home"
const val WALLET_ADDRESS_DETAILS = "wallet_address_details"
const val SEND = "send"
}

View File

@ -6,7 +6,7 @@ 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.demoapp.util.fromResources
import cash.z.ecc.android.sdk.demoapp.type.fromResources
import cash.z.ecc.android.sdk.ext.BenchmarkingExt
import cash.z.ecc.android.sdk.ext.onFirst
import cash.z.ecc.android.sdk.fixture.BlockRangeFixture

View File

@ -0,0 +1,163 @@
package cash.z.ecc.android.sdk.demoapp
import android.content.Context
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.demoapp.model.PersistableWallet
import cash.z.ecc.android.sdk.demoapp.type.fromResources
import cash.z.ecc.android.sdk.demoapp.util.Twig
import cash.z.ecc.android.sdk.ext.onFirst
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.defaultForNetwork
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.UUID
/**
* @param persistableWallet flow of the user's stored wallet. Null indicates that no wallet has been stored.
*/
class WalletCoordinator(context: Context, val persistableWallet: Flow<PersistableWallet?>) {
private val applicationContext = context.applicationContext
/*
* We want a global scope that is independent of the lifecycles of either
* WorkManager or the UI.
*/
@OptIn(DelicateCoroutinesApi::class)
private val walletScope = CoroutineScope(GlobalScope.coroutineContext + Dispatchers.Main)
private val synchronizerMutex = Mutex()
private val lockoutMutex = Mutex()
private val synchronizerLockoutId = MutableStateFlow<UUID?>(null)
private sealed class InternalSynchronizerStatus {
object NoWallet : InternalSynchronizerStatus()
class Available(val synchronizer: Synchronizer) : InternalSynchronizerStatus()
class Lockout(val id: UUID) : InternalSynchronizerStatus()
}
private val synchronizerOrLockoutId: Flow<Flow<InternalSynchronizerStatus>> = persistableWallet
.combine(synchronizerLockoutId) { persistableWallet: PersistableWallet?, lockoutId: UUID? ->
if (null != lockoutId) { // this one needs to come first
flowOf(InternalSynchronizerStatus.Lockout(lockoutId))
} else if (null == persistableWallet) {
flowOf(InternalSynchronizerStatus.NoWallet)
} else {
callbackFlow<InternalSynchronizerStatus.Available> {
val closeableSynchronizer = Synchronizer.new(
context = context,
zcashNetwork = persistableWallet.network,
lightWalletEndpoint = LightWalletEndpoint.defaultForNetwork(persistableWallet.network),
birthday = persistableWallet.birthday,
seed = persistableWallet.seedPhrase.toByteArray()
)
trySend(InternalSynchronizerStatus.Available(closeableSynchronizer))
awaitClose {
Twig.info { "Closing flow and stopping synchronizer" }
closeableSynchronizer.close()
}
}
}
}
/**
* Synchronizer for the Zcash SDK. Emits null until a wallet secret is persisted.
*
* Note that this synchronizer is closed as soon as it stops being collected. For UI use
* cases, see [WalletViewModel].
*/
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
val synchronizer: StateFlow<Synchronizer?> = synchronizerOrLockoutId
.flatMapLatest {
it
}
.map {
when (it) {
is InternalSynchronizerStatus.Available -> it.synchronizer
is InternalSynchronizerStatus.Lockout -> null
InternalSynchronizerStatus.NoWallet -> null
}
}
.stateIn(
walletScope,
SharingStarted.WhileSubscribed(),
null
)
/**
* Rescans the blockchain.
*
* In order for a rescan to occur, the synchronizer must be loaded already
* which would happen if the UI is collecting it.
*
* @return True if the rescan was performed and false if the rescan was not performed.
*/
suspend fun rescanBlockchain(): Boolean {
synchronizerMutex.withLock {
synchronizer.value?.let {
it.latestBirthdayHeight?.let { height ->
it.rewindToNearestHeight(height, true)
return true
}
}
}
return false
}
/**
* Resets persisted data in the SDK, but preserves the wallet secret. This will cause the
* WalletCoordinator to emit a new synchronizer instance.
*/
@OptIn(FlowPreview::class)
fun resetSdk() {
walletScope.launch {
lockoutMutex.withLock {
val lockoutId = UUID.randomUUID()
synchronizerLockoutId.value = lockoutId
synchronizerOrLockoutId
.flatMapConcat { it }
.filterIsInstance<InternalSynchronizerStatus.Lockout>()
.filter { it.id == lockoutId }
.onFirst {
synchronizerMutex.withLock {
val didDelete = Synchronizer.erase(
appContext = applicationContext,
network = ZcashNetwork.fromResources(applicationContext)
)
Twig.info { "SDK erase result: $didDelete" }
}
}
synchronizerLockoutId.value = null
}
}
}
// Allows for extension functions
companion object
}

View File

@ -0,0 +1,25 @@
package cash.z.ecc.android.sdk.demoapp
import android.content.Context
import cash.z.ecc.android.sdk.demoapp.preference.EncryptedPreferenceKeys
import cash.z.ecc.android.sdk.demoapp.preference.EncryptedPreferenceSingleton
import cash.z.ecc.android.sdk.demoapp.util.LazyWithArgument
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
private val lazy = LazyWithArgument<Context, WalletCoordinator> {
/*
* A flow of the user's stored wallet. Null indicates that no wallet has been stored.
*/
val persistableWalletFlow = flow {
// EncryptedPreferenceSingleton.getInstance() is a suspending function, which is why we need
// the flow builder to provide a coroutine context.
val encryptedPreferenceProvider = EncryptedPreferenceSingleton.getInstance(it)
emitAll(EncryptedPreferenceKeys.PERSISTABLE_WALLET.observe(encryptedPreferenceProvider))
}
WalletCoordinator(it, persistableWalletFlow)
}
fun WalletCoordinator.Companion.getInstance(context: Context) = lazy.getInstance(context)

View File

@ -9,8 +9,8 @@ import androidx.lifecycle.repeatOnLifecycle
import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetAddressBinding
import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext
import cash.z.ecc.android.sdk.demoapp.type.fromResources
import cash.z.ecc.android.sdk.demoapp.util.ProvideAddressBenchmarkTrace
import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.type.UnifiedFullViewingKey

View File

@ -15,8 +15,8 @@ import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment
import cash.z.ecc.android.sdk.demoapp.R
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetBalanceBinding
import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext
import cash.z.ecc.android.sdk.demoapp.type.fromResources
import cash.z.ecc.android.sdk.demoapp.util.SyncBlockchainBenchmarkTrace
import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
import cash.z.ecc.android.sdk.internal.twig

View File

@ -7,7 +7,7 @@ import androidx.core.text.HtmlCompat
import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetBlockBinding
import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext
import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.demoapp.type.fromResources
import cash.z.ecc.android.sdk.demoapp.util.mainActivity
import cash.z.ecc.android.sdk.demoapp.util.toHtml
import cash.z.ecc.android.sdk.demoapp.util.toRelativeTime

View File

@ -8,7 +8,7 @@ import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment
import cash.z.ecc.android.sdk.demoapp.R
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetBlockRangeBinding
import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext
import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.demoapp.type.fromResources
import cash.z.ecc.android.sdk.demoapp.util.mainActivity
import cash.z.ecc.android.sdk.demoapp.util.toRelativeTime
import cash.z.ecc.android.sdk.demoapp.util.withCommas

View File

@ -10,7 +10,7 @@ import cash.z.ecc.android.bip39.toSeed
import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetPrivateKeyBinding
import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext
import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.demoapp.type.fromResources
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.tool.DerivationTool

View File

@ -14,7 +14,7 @@ import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentListUtxosBinding
import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext
import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.demoapp.type.fromResources
import cash.z.ecc.android.sdk.demoapp.util.mainActivity
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.Account

View File

@ -0,0 +1,9 @@
@file:Suppress("ktlint:filename")
package cash.z.ecc.android.sdk.demoapp.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,90 @@
@file:Suppress("ktlint:filename")
package cash.z.ecc.android.sdk.demoapp.ext.ui
import cash.z.ecc.android.sdk.demoapp.model.CurrencyConversion
import cash.z.ecc.android.sdk.demoapp.model.FiatCurrencyConversionRateState
import cash.z.ecc.android.sdk.demoapp.model.Locale
import cash.z.ecc.android.sdk.demoapp.model.MonetarySeparators
import cash.z.ecc.android.sdk.demoapp.model.toJavaLocale
import cash.z.ecc.android.sdk.ext.Conversions
import cash.z.ecc.android.sdk.model.Zatoshi
import kotlinx.datetime.Clock
import java.math.BigDecimal
import java.math.MathContext
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Currency
import kotlin.time.Duration
fun Zatoshi.toFiatCurrencyState(
currencyConversion: CurrencyConversion?,
locale: Locale,
monetarySeparators: MonetarySeparators,
clock: Clock = Clock.System
): FiatCurrencyConversionRateState {
if (currencyConversion == null) {
return FiatCurrencyConversionRateState.Unavailable
}
val fiatCurrencyConversionRate = toFiatString(currencyConversion, locale, monetarySeparators)
val currentSystemTime = clock.now()
val age = currentSystemTime - currencyConversion.timestamp
return if (age < Duration.ZERO && age.absoluteValue > FiatCurrencyConversionRateState.FUTURE_CUTOFF_AGE_INCLUSIVE) {
// Special case if the device's clock is set to the future.
// TODO [#535]: Consider using NTP requests to get the correct time instead of relying on the device's clock.
FiatCurrencyConversionRateState.Unavailable
} else if (age <= FiatCurrencyConversionRateState.CURRENT_CUTOFF_AGE_INCLUSIVE) {
FiatCurrencyConversionRateState.Current(fiatCurrencyConversionRate)
} else if (age <= FiatCurrencyConversionRateState.STALE_CUTOFF_AGE_INCLUSIVE) {
FiatCurrencyConversionRateState.Stale(fiatCurrencyConversionRate)
} else {
FiatCurrencyConversionRateState.Unavailable
}
}
fun Zatoshi.toFiatString(
currencyConversion: CurrencyConversion,
locale: Locale,
monetarySeparators: MonetarySeparators
) =
convertZatoshiToZecDecimal()
.convertZecDecimalToFiatDecimal(BigDecimal(currencyConversion.priceOfZec))
.convertFiatDecimalToFiatString(
Currency.getInstance(currencyConversion.fiatCurrency.code),
locale.toJavaLocale(),
monetarySeparators
)
private fun Zatoshi.convertZatoshiToZecDecimal(): BigDecimal {
return BigDecimal(value, MathContext.DECIMAL128).divide(
Conversions.ONE_ZEC_IN_ZATOSHI,
MathContext.DECIMAL128
).setScale(Conversions.ZEC_FORMATTER.maximumFractionDigits, RoundingMode.HALF_EVEN)
}
private fun BigDecimal.convertZecDecimalToFiatDecimal(zecPrice: BigDecimal): BigDecimal {
return multiply(zecPrice, MathContext.DECIMAL128)
}
private fun BigDecimal.convertFiatDecimalToFiatString(
fiatCurrency: Currency,
locale: java.util.Locale,
monetarySeparators: MonetarySeparators
): String {
return NumberFormat.getCurrencyInstance(locale).apply {
currency = fiatCurrency
roundingMode = RoundingMode.HALF_EVEN
if (this is DecimalFormat) {
decimalFormatSymbols.apply {
decimalSeparator = monetarySeparators.decimal
monetaryDecimalSeparator = monetarySeparators.decimal
groupingSeparator = monetarySeparators.grouping
}
}
}.format(this)
}

View File

@ -0,0 +1,17 @@
package cash.z.ecc.android.sdk.demoapp.fixture
import cash.z.ecc.android.sdk.demoapp.model.WalletAddress
object WalletAddressFixture {
// These fixture values are derived from the secret defined in PersistableWalletFixture
const val UNIFIED_ADDRESS_STRING = "Unified GitHub Issue #161"
@Suppress("MaxLineLength")
const val SAPLING_ADDRESS_STRING = "zs1hf72k87gev2qnvg9228vn2xt97adfelju2hm2ap4xwrxkau5dz56mvkeseer3u8283wmy7skt4u"
const val TRANSPARENT_ADDRESS_STRING = "t1QZMTZaU1EwXppCLL5dR6U9y2M4ph3CSPK"
suspend fun unified() = WalletAddress.Unified.new(UNIFIED_ADDRESS_STRING)
suspend fun sapling() = WalletAddress.Sapling.new(SAPLING_ADDRESS_STRING)
suspend fun transparent() = WalletAddress.Transparent.new(TRANSPARENT_ADDRESS_STRING)
}

View File

@ -0,0 +1,17 @@
package cash.z.ecc.android.sdk.demoapp.fixture
import cash.z.ecc.android.sdk.demoapp.model.WalletAddress
import cash.z.ecc.android.sdk.demoapp.model.WalletAddresses
object WalletAddressesFixture {
suspend fun new(
unified: String = WalletAddressFixture.UNIFIED_ADDRESS_STRING,
sapling: String = WalletAddressFixture.SAPLING_ADDRESS_STRING,
transparent: String = WalletAddressFixture.TRANSPARENT_ADDRESS_STRING
) = WalletAddresses(
WalletAddress.Unified.new(unified),
WalletAddress.Sapling.new(sapling),
WalletAddress.Transparent.new(transparent)
)
}

View File

@ -0,0 +1,79 @@
package cash.z.ecc.android.sdk.demoapp.fixture
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.tool.DerivationTool
/**
* Provides two default wallets, making it easy to test sending funds back and forth between them.
*/
sealed class WalletFixture {
abstract val seedPhrase: String
abstract fun getAddresses(zcashNetwork: ZcashNetwork): Addresses
suspend fun getUnifiedSpendingKey(
seed: String = seedPhrase,
network: ZcashNetwork,
account: Account = Account.DEFAULT
) = DerivationTool.deriveUnifiedSpendingKey(Mnemonics.MnemonicCode(seed).toEntropy(), network, account)
@Suppress("MaxLineLength")
object Bob : WalletFixture() {
override val seedPhrase: String
get() = "kitchen renew wide common vague fold vacuum tilt amazing pear square gossip jewel month tree shock scan alpha just spot fluid toilet view dinner"
override fun getAddresses(zcashNetwork: ZcashNetwork) = when (zcashNetwork.id) {
ZcashNetwork.ID_TESTNET -> {
Addresses(
unified =
"utest1vergg5jkp4xy8sqfasw6s5zkdpnxvfxlxh35uuc3me7dp596y2r05t6dv9htwe3pf8ksrfr8ksca2lskzjanqtl8uqp5vln3zyy246ejtx86vqftp73j7jg9099jxafyjhfm6u956j3",
sapling =
"ztestsapling17mg40levjezevuhdp5pqrd52zere7r7vrjgdwn5sj4xsqtm20euwahv9anxmwr3y3kmwu2syhnf",
transparent = "tmP3uLtGx5GPddkq8a6ddmXhqJJ3vy6tpTE"
)
}
ZcashNetwork.ID_MAINNET -> {
Addresses(
unified =
"u1lmy8anuylj33arxh3sx7ysq54tuw7zehsv6pdeeaqlrhkjhm3uvl9egqxqfd7hcsp3mszp6jxxx0gsw0ldp5wyu95r4mfzlueh8h5xhrjqgz7xtxp3hvw45dn4gfrz5j54ryg6reyf0",
sapling =
"zs1t06xldkqkayhp0lj98kunuq6gz3md0lw3r7q2x82rc94dy8z3hsjhuh6smpnlg9c2za3sq34w5m",
transparent = "t1JP7PHu72xHztsZiwH6cye4yvC9Prb3EvQ"
)
}
else -> error("Unknown network $zcashNetwork")
}
}
@Suppress("MaxLineLength")
object Alice : WalletFixture() {
override val seedPhrase: String
get() = "wish puppy smile loan doll curve hole maze file ginger hair nose key relax knife witness cannon grab despair throw review deal slush frame"
override fun getAddresses(zcashNetwork: ZcashNetwork) = when (zcashNetwork.id) {
ZcashNetwork.ID_TESTNET -> {
Addresses(
unified =
"utest16zd8zfx6n6few7mjsjpn6qtn8tlg6law7qnq33257855mdqekk7vru8lettx3vud4mh99elglddltmfjkduar69h7vy08h3xdq6zuls9pqq7quyuehjqwtthc3hfd8gshhw42dfr96e",
sapling =
"ztestsapling1zhqvuq8zdwa8nsnde7074kcfsat0w25n08jzuvz5skzcs6h9raxu898l48xwr8fmkny3zqqrgd9",
transparent = "tmCxJG72RWN66xwPtNgu4iKHpyysGrc7rEg"
)
}
ZcashNetwork.ID_MAINNET -> {
Addresses(
unified =
"u1czzc8jcl50svfezmfc9xsxnh63p374nptqplt0yw2uekr7v9wprp84y6esys6derp6uvdcq6x6ykjrkpdyhjzneq5ud78h6j68n63hewg7xp9fpneuh64wgzt3d7mh6zh3qpqapzlc4",
sapling =
"zs15tzaulx5weua5c7l47l4pku2pw9fzwvvnsp4y80jdpul0y3nwn5zp7tmkcclqaca3mdjqjkl7hx",
transparent = "t1duiEGg7b39nfQee3XaTY4f5McqfyJKhBi"
)
}
else -> error("Unknown network $zcashNetwork")
}
}
}
data class Addresses(val unified: String, val sapling: String, val transparent: String)

View File

@ -0,0 +1,47 @@
package cash.z.ecc.android.sdk.demoapp.fixture
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.demoapp.model.PercentDecimal
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel.SynchronizerError
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel.WalletSnapshot
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
@Suppress("MagicNumber")
object WalletSnapshotFixture {
val STATUS = Synchronizer.Status.SYNCED
val PROGRESS = PercentDecimal.ZERO_PERCENT
val TRANSPARENT_BALANCE: WalletBalance = WalletBalance(Zatoshi(8), Zatoshi(1))
val ORCHARD_BALANCE: WalletBalance = WalletBalance(Zatoshi(5), Zatoshi(2))
val SAPLING_BALANCE: WalletBalance = WalletBalance(Zatoshi(4), Zatoshi(4))
// Should fill in with non-empty values for better example values in tests and UI previews
@Suppress("LongParameterList")
fun new(
status: Synchronizer.Status = STATUS,
processorInfo: CompactBlockProcessor.ProcessorInfo = CompactBlockProcessor.ProcessorInfo(
null,
null,
null,
null,
null
),
orchardBalance: WalletBalance = ORCHARD_BALANCE,
saplingBalance: WalletBalance = SAPLING_BALANCE,
transparentBalance: WalletBalance = TRANSPARENT_BALANCE,
pendingCount: Int = 0,
progress: PercentDecimal = PROGRESS,
synchronizerError: SynchronizerError? = null
) = WalletSnapshot(
status,
processorInfo,
orchardBalance,
saplingBalance,
transparentBalance,
pendingCount,
progress,
synchronizerError
)
}

View File

@ -0,0 +1,35 @@
package cash.z.ecc.android.sdk.demoapp.model
import kotlinx.datetime.Instant
/**
* Represents a snapshot in time of a currency conversion rate.
*
* @param fiatCurrency The fiat currency for this conversion.
* @param timestamp The timestamp when this conversion was obtained. This value is returned by
* the server so it shouldn't have issues with client-side clock inaccuracy.
* @param priceOfZec The conversion rate of ZEC to the fiat currency.
*/
data class CurrencyConversion(
val fiatCurrency: FiatCurrency,
val timestamp: Instant,
val priceOfZec: Double
) {
init {
require(priceOfZec > 0) { "priceOfZec must be greater than 0" }
require(priceOfZec.isFinite()) { "priceOfZec must be finite" }
}
}
/**
* Represents an ISO 4217 currency code.
*/
@Suppress("MagicNumber")
data class FiatCurrency(val code: String) {
init {
require(code.length == 3) { "Fiat currency code must be 3 characters long." }
// TODO [#532] https://github.com/zcash/secant-android-wallet/issues/532
// Add another check to make sure the code is in the known ISO currency code list.
}
}

View File

@ -0,0 +1,40 @@
package cash.z.ecc.android.sdk.demoapp.model
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.minutes
/**
* Represents a state of current fiat currency conversion to ZECs.
*/
sealed class FiatCurrencyConversionRateState {
/**
* @param formattedFiatValue A fiat value formatted as a localized string. E.g. $1.00.
*/
data class Current(val formattedFiatValue: String) : FiatCurrencyConversionRateState()
/**
* @param formattedFiatValue A fiat value formatted as a localized string. E.g. $1.00.
*/
data class Stale(val formattedFiatValue: String) : FiatCurrencyConversionRateState()
object Unavailable : FiatCurrencyConversionRateState()
companion object {
/**
* Cutoff negative age. Some users may intentionally set their clock forward 10 minutes
* because they're always late to things. This allows the app to mostly work for those users,
* while still failing if the clock is way off.
*/
val FUTURE_CUTOFF_AGE_INCLUSIVE = 10.minutes
/**
* Cutoff age for next attempt to refresh the conversion rate from the API.
*/
val CURRENT_CUTOFF_AGE_INCLUSIVE = 1.minutes
/**
* Cutoff age for displaying conversion rate from prior app launches or background refresh.
*/
val STALE_CUTOFF_AGE_INCLUSIVE = 1.days
}
}

View File

@ -0,0 +1,31 @@
package cash.z.ecc.android.sdk.demoapp.model
data class Locale(val language: String, val region: String?, val variant: String?) {
companion object
}
fun Locale.toJavaLocale(): java.util.Locale {
return if (!region.isNullOrEmpty() && !variant.isNullOrEmpty()) {
java.util.Locale(language, region, variant)
} else if (!region.isNullOrEmpty() && variant.isNullOrEmpty()) {
java.util.Locale(language, region)
} else {
java.util.Locale(language)
}
}
fun java.util.Locale.toKotlinLocale(): Locale {
val resultCountry = if (country.isNullOrEmpty()) {
null
} else {
country
}
val resultVariant = if (variant.isNullOrEmpty()) {
null
} else {
variant
}
return Locale(language, resultCountry, resultVariant)
}

View File

@ -0,0 +1,24 @@
package cash.z.ecc.android.sdk.demoapp.model
import cash.z.ecc.android.sdk.demoapp.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

@ -0,0 +1,25 @@
package cash.z.ecc.android.sdk.demoapp.model
/**
* @param decimal A percent represented as a `Double` decimal value in the range of [0, 1].
*/
@JvmInline
value class PercentDecimal(val decimal: Float) {
init {
require(decimal >= MIN)
require(decimal <= MAX)
}
companion object {
const val MIN = 0.0f
const val MAX = 1.0f
val ZERO_PERCENT = PercentDecimal(MIN)
val ONE_HUNDRED_PERCENT = PercentDecimal(MAX)
fun newLenient(decimal: Float) = PercentDecimal(
decimal
.coerceAtLeast(MIN)
.coerceAtMost(MAX)
)
}
}

View File

@ -0,0 +1,89 @@
package cash.z.ecc.android.sdk.demoapp.model
import android.app.Application
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toEntropy
import cash.z.ecc.android.sdk.demoapp.type.fromResources
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
* Represents everything needed to save and restore a wallet.
*/
data class PersistableWallet(
val network: ZcashNetwork,
val birthday: BlockHeight?,
val seedPhrase: SeedPhrase
) {
/**
* @return Wallet serialized to JSON format, suitable for long-term encrypted storage.
*/
// Note: We're using a hand-crafted serializer so that we're less likely to have accidental
// breakage from reflection or annotation based methods, and so that we can carefully manage versioning.
fun toJson() = JSONObject().apply {
put(KEY_VERSION, VERSION_1)
put(KEY_NETWORK_ID, network.id)
birthday?.let {
put(KEY_BIRTHDAY, it.value)
}
put(KEY_SEED_PHRASE, seedPhrase.joinToString())
}
// For security, intentionally override the toString method to reduce risk of accidentally logging secrets
override fun toString() = "PersistableWallet"
companion object {
private const val VERSION_1 = 1
internal const val KEY_VERSION = "v"
internal const val KEY_NETWORK_ID = "network_ID"
internal const val KEY_BIRTHDAY = "birthday"
internal const val KEY_SEED_PHRASE = "seed_phrase"
fun from(jsonObject: JSONObject): PersistableWallet {
when (val version = jsonObject.getInt(KEY_VERSION)) {
VERSION_1 -> {
val network = run {
val networkId = jsonObject.getInt(KEY_NETWORK_ID)
ZcashNetwork.from(networkId)
}
val birthday = if (jsonObject.has(KEY_BIRTHDAY)) {
val birthdayBlockHeightLong = jsonObject.getLong(KEY_BIRTHDAY)
BlockHeight.new(network, birthdayBlockHeightLong)
} else {
null
}
val seedPhrase = jsonObject.getString(KEY_SEED_PHRASE)
return PersistableWallet(network, birthday, SeedPhrase.new(seedPhrase))
}
else -> {
throw IllegalArgumentException("Unsupported version $version")
}
}
}
/**
* @return A new PersistableWallet with a random seed phrase.
*/
suspend fun new(application: Application): PersistableWallet {
val zcashNetwork = ZcashNetwork.fromResources(application)
val birthday = BlockHeight.ofLatestCheckpoint(application, zcashNetwork)
val seedPhrase = newSeedPhrase()
return PersistableWallet(zcashNetwork, birthday, seedPhrase)
}
}
}
// Using IO context because of https://github.com/zcash/kotlin-bip39/issues/13
private suspend fun newMnemonic() = withContext(Dispatchers.IO) {
Mnemonics.MnemonicCode(cash.z.ecc.android.bip39.Mnemonics.WordCount.COUNT_24.toEntropy()).words
}
private suspend fun newSeedPhrase() = SeedPhrase(newMnemonic().map { it.concatToString() })

View File

@ -0,0 +1,25 @@
package cash.z.ecc.android.sdk.demoapp.model
// Consider using ImmutableList here
data class SeedPhrase(val split: List<String>) {
init {
require(SEED_PHRASE_SIZE == split.size) {
"Seed phrase must split into $SEED_PHRASE_SIZE words but was ${split.size}"
}
}
// For security, intentionally override the toString method to reduce risk of accidentally logging secrets
override fun toString() = "SeedPhrase"
fun joinToString() = split.joinToString(DEFAULT_DELIMITER)
fun toByteArray() = joinToString().encodeToByteArray()
companion object {
const val SEED_PHRASE_SIZE = 24
const val DEFAULT_DELIMITER = " "
fun new(phrase: String) = SeedPhrase(phrase.split(DEFAULT_DELIMITER))
}
}

View File

@ -0,0 +1,46 @@
package cash.z.ecc.android.sdk.demoapp.model
sealed class WalletAddress(val address: String) {
class Unified private constructor(address: String) : WalletAddress(address) {
companion object {
suspend fun new(address: String): WalletAddress.Unified {
// https://github.com/zcash/zcash-android-wallet-sdk/issues/342
// TODO [#342]: refactor SDK to enable direct calls for address verification
return WalletAddress.Unified(address)
}
}
}
class Sapling private constructor(address: String) : WalletAddress(address) {
companion object {
suspend fun new(address: String): Sapling {
// TODO [#342]: https://github.com/zcash/zcash-android-wallet-sdk/issues/342
// TODO [#342]: refactor SDK to enable direct calls for address verification
return Sapling(address)
}
}
}
class Transparent private constructor(address: String) : WalletAddress(address) {
companion object {
suspend fun new(address: String): Transparent {
// TODO [#342]: https://github.com/zcash/zcash-android-wallet-sdk/issues/342
// TODO [#342]: refactor SDK to enable direct calls for address verification
return Transparent(address)
}
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as WalletAddress
if (address != other.address) return false
return true
}
override fun hashCode() = address.hashCode()
}

View File

@ -0,0 +1,35 @@
package cash.z.ecc.android.sdk.demoapp.model
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.Account
data class WalletAddresses(
val unified: WalletAddress.Unified,
val sapling: WalletAddress.Sapling,
val transparent: WalletAddress.Transparent
) {
// Override to prevent leaking details in logs
override fun toString() = "WalletAddresses"
companion object {
suspend fun new(synchronizer: Synchronizer): WalletAddresses {
val unified = WalletAddress.Unified.new(
synchronizer.getUnifiedAddress(Account.DEFAULT)
)
val saplingAddress = WalletAddress.Sapling.new(
synchronizer.getSaplingAddress(Account.DEFAULT)
)
val transparentAddress = WalletAddress.Transparent.new(
synchronizer.getTransparentAddress(Account.DEFAULT)
)
return WalletAddresses(
unified = unified,
sapling = saplingAddress,
transparent = transparentAddress
)
}
}
}

View File

@ -0,0 +1,16 @@
package cash.z.ecc.android.sdk.demoapp.model
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.Zatoshi
data class ZecSend(val destination: WalletAddress, val amount: Zatoshi, val memo: Memo) {
companion object
}
fun Synchronizer.send(spendingKey: UnifiedSpendingKey, send: ZecSend) = sendToAddress(
spendingKey,
send.amount,
send.destination.address,
send.memo.value
)

View File

@ -0,0 +1,47 @@
package cash.z.ecc.android.sdk.demoapp.model
import android.content.Context
import cash.z.ecc.android.sdk.model.Zatoshi
import kotlinx.coroutines.runBlocking
object ZecSendExt {
fun new(
context: Context,
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(context, zecString, monetarySeparators)
val memo = Memo(memoString)
val validationErrors = buildSet {
if (null == amount) {
add(ZecSendValidation.Invalid.ValidationError.INVALID_AMOUNT)
}
// 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

@ -0,0 +1,106 @@
package cash.z.ecc.android.sdk.demoapp.model
import android.content.Context
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
import cash.z.ecc.android.sdk.ext.convertZecToZatoshi
import cash.z.ecc.android.sdk.model.Zatoshi
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.text.ParseException
import java.util.Locale
object ZecString {
fun allowedCharacters(monetarySeparators: MonetarySeparators) = buildSet<Char> {
add('0')
add('1')
add('2')
add('3')
add('4')
add('5')
add('6')
add('7')
add('8')
add('9')
add(monetarySeparators.decimal)
add(monetarySeparators.grouping)
}
}
data class MonetarySeparators(val grouping: Char, val decimal: Char) {
init {
require(grouping != decimal) { "Grouping and decimal separator cannot be the same character" }
}
companion object {
/**
* @return The current localized monetary separators. Do not cache this value, as it
* can change if the system Locale changes.
*/
fun current(): MonetarySeparators {
val decimalFormatSymbols = DecimalFormatSymbols.getInstance()
return MonetarySeparators(
decimalFormatSymbols.groupingSeparator,
decimalFormatSymbols.monetaryDecimalSeparator
)
}
}
}
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() = convertZatoshiToZecString(DECIMALS, DECIMALS)
/*
* ZEC is our own currency, so there's not going to be an existing localization that matches it perfectly.
*
* To ensure consistent behavior regardless of user Locale, use US localization except that we swap out the
* separator characters based on the user's current Locale. This should avoid unexpected surprises
* while also localizing the separator format.
*/
/**
* @return [zecString] parsed into Zatoshi or null if parsing failed.
*/
@SuppressWarnings("ReturnCount")
fun Zatoshi.Companion.fromZecString(
context: Context,
zecString: String,
monetarySeparators: MonetarySeparators
): Zatoshi? {
if (!ZecStringExt.filterConfirm(context, monetarySeparators, zecString)) {
return null
}
val symbols = DecimalFormatSymbols.getInstance(Locale.US).apply {
this.groupingSeparator = monetarySeparators.grouping
this.decimalSeparator = monetarySeparators.decimal
}
val localizedPattern = "#${monetarySeparators.grouping}##0${monetarySeparators.decimal}0#"
// TODO [#321]: https://github.com/zcash/secant-android-wallet/issues/321
val decimalFormat = DecimalFormat(localizedPattern, symbols).apply {
isParseBigDecimal = true
roundingMode = RoundingMode.HALF_EVEN // aka Bankers rounding
}
// TODO [#343]: https://github.com/zcash/secant-android-wallet/issues/343
val bigDecimal = try {
decimalFormat.parse(zecString) as BigDecimal
} catch (e: NumberFormatException) {
null
} catch (e: ParseException) {
null
}
@Suppress("SwallowedException")
return try {
bigDecimal.convertZecToZatoshi()
} catch (e: IllegalArgumentException) {
null
}
}

View File

@ -0,0 +1,91 @@
package cash.z.ecc.android.sdk.demoapp.model
import android.content.Context
import cash.z.ecc.android.sdk.demoapp.R
object ZecStringExt {
private const val DIGITS_BETWEEN_GROUP_SEPARATORS = 3
/**
* Builds filter with current local monetary separators for continuous input checking. The
* solution is built upon regex validation and other common string validation checks.
*
* Regex example: ^([0-9]*([0-9]+([,]$|[,][0-9]+))*([.]$|[.][0-9]+)?)?$
* Inputs may differ according to user locale.
*
* Valid amounts: "" . | .123 | 123, | 123. | 123,456 | 123.456 | 123,456.789 | 123,456,789 | 123,456,789.123 | etc.
* Invalid amounts: 123,, | 123,. | 123.. | ,123 | 123.456.789 | etc.
*
* @param context used for loading localized pattern from strings.xml
* @param separators which consist of localized monetary separators
* @param zecString to be validated
*
* @return true in case of validation success, false otherwise
*/
fun filterContinuous(context: Context, separators: MonetarySeparators, zecString: String): Boolean {
if (!context.getString(
R.string.co_electriccoin_zcash_zec_amount_regex_continuous_filter,
separators.grouping,
separators.decimal
).toRegex().matches(zecString) || !checkFor3Digits(separators, zecString)
) {
return false
}
return true
}
/**
* Checks for at least 3 digits between grouping separators.
*
* @param separators which consist of localized monetary separators
* @param zecString to be validated
*
* @return true in case of validation success, false otherwise
*/
fun checkFor3Digits(separators: MonetarySeparators, zecString: String): Boolean {
if (zecString.count { it == separators.grouping } >= 2) {
val groups = zecString.split(separators.grouping)
for (i in 1 until (groups.size - 1)) {
if (groups[i].length != DIGITS_BETWEEN_GROUP_SEPARATORS) {
return false
}
}
}
return true
}
/**
* Builds filter with current local monetary separators for validation of entered ZEC amount
* after confirm button is pressed. The solution is built upon regex validation and other common
* string validation checks.
*
* Regex example: ^([0-9]{1,3}(?:[,]?[0-9]{3})*)*(?:[0-9]*[.][0-9]*)?$
* Inputs may differ according to user locale.
*
* Valid amounts: 123 | .123 | 123. | 123, | 123.456 | 123,456 | 123,456.789 | 123,456,789 | 123,456,789.123 | etc.
* Invalid amounts: "" | , | . | 123,, | 123,. | 123.. | ,123 | 123.456.789 | etc.
*
* @param context used for loading localized pattern from strings.xml
* @param separators which consist of localized monetary separators
* @param zecString to be validated
*
* @return true in case of validation success, false otherwise
*/
fun filterConfirm(context: Context, separators: MonetarySeparators, zecString: String): Boolean {
if (zecString.isBlank() ||
zecString == separators.grouping.toString() ||
zecString == separators.decimal.toString()
) {
return false
}
return (
context.getString(
R.string.co_electriccoin_zcash_zec_amount_regex_confirm_filter,
separators.grouping,
separators.decimal
).toRegex().matches(zecString) && checkFor3Digits(separators, zecString)
)
}
}

View File

@ -0,0 +1,116 @@
package cash.z.ecc.android.sdk.demoapp.preference
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import cash.z.ecc.android.sdk.demoapp.preference.api.PreferenceProvider
import cash.z.ecc.android.sdk.demoapp.preference.model.entry.Key
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import java.util.concurrent.Executors
/**
* Provides an Android implementation of shared preferences.
*
* This class is thread-safe.
*
* For a given preference file, it is expected that only a single instance is constructed and that
* this instance lives for the lifetime of the application. Constructing multiple instances will
* potentially corrupt preference data and will leak resources.
*/
/*
* Implementation note: EncryptedSharedPreferences are not thread-safe, so this implementation
* confines them to a single background thread.
*/
class AndroidPreferenceProvider(
private val sharedPreferences: SharedPreferences,
private val dispatcher: CoroutineDispatcher
) : PreferenceProvider {
override suspend fun hasKey(key: Key) = withContext(dispatcher) {
sharedPreferences.contains(key.key)
}
@SuppressLint("ApplySharedPref")
override suspend fun putString(key: Key, value: String?) = withContext(dispatcher) {
val editor = sharedPreferences.edit()
editor.putString(key.key, value)
editor.commit()
Unit
}
override suspend fun getString(key: Key) = withContext(dispatcher) {
sharedPreferences.getString(key.key, null)
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun observe(key: Key): Flow<Unit> = callbackFlow<Unit> {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ ->
// Callback on main thread
trySend(Unit)
}
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
// Kickstart the emissions
trySend(Unit)
awaitClose {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
}
}.flowOn(dispatcher)
companion object {
suspend fun newStandard(context: Context, filename: String): PreferenceProvider {
/*
* Because of this line, we don't want multiple instances of this object created
* because we don't clean up the thread afterwards.
*/
val singleThreadedDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
val sharedPreferences = withContext(singleThreadedDispatcher) {
context.getSharedPreferences(filename, Context.MODE_PRIVATE)
}
return AndroidPreferenceProvider(sharedPreferences, singleThreadedDispatcher)
}
suspend fun newEncrypted(context: Context, filename: String): PreferenceProvider {
/*
* Because of this line, we don't want multiple instances of this object created
* because we don't clean up the thread afterwards.
*/
val singleThreadedDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
val mainKey = withContext(singleThreadedDispatcher) {
@Suppress("BlockingMethodInNonBlockingContext")
MasterKey.Builder(context).apply {
setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
}.build()
}
val sharedPreferences = withContext(singleThreadedDispatcher) {
@Suppress("BlockingMethodInNonBlockingContext")
EncryptedSharedPreferences.create(
context,
filename,
mainKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
return AndroidPreferenceProvider(sharedPreferences, singleThreadedDispatcher)
}
}
}

View File

@ -0,0 +1,8 @@
package cash.z.ecc.android.sdk.demoapp.preference
import cash.z.ecc.android.sdk.demoapp.preference.model.entry.Key
object EncryptedPreferenceKeys {
val PERSISTABLE_WALLET = PersistableWalletPreferenceDefault(Key("persistable_wallet"))
}

View File

@ -0,0 +1,16 @@
package cash.z.ecc.android.sdk.demoapp.preference
import android.content.Context
import cash.z.ecc.android.sdk.demoapp.preference.api.PreferenceProvider
import cash.z.ecc.android.sdk.demoapp.util.SuspendingLazy
object EncryptedPreferenceSingleton {
private const val PREF_FILENAME = "co.electriccoin.zcash.encrypted"
private val lazy = SuspendingLazy<Context, PreferenceProvider> {
AndroidPreferenceProvider.newEncrypted(it, PREF_FILENAME)
}
suspend fun getInstance(context: Context) = lazy.getInstance(context)
}

View File

@ -0,0 +1,20 @@
package cash.z.ecc.android.sdk.demoapp.preference
import cash.z.ecc.android.sdk.demoapp.model.PersistableWallet
import cash.z.ecc.android.sdk.demoapp.preference.api.PreferenceProvider
import cash.z.ecc.android.sdk.demoapp.preference.model.entry.Key
import cash.z.ecc.android.sdk.demoapp.preference.model.entry.PreferenceDefault
import org.json.JSONObject
data class PersistableWalletPreferenceDefault(
override val key: Key
) : PreferenceDefault<PersistableWallet?> {
override suspend fun getValue(preferenceProvider: PreferenceProvider) =
preferenceProvider.getString(key)?.let { PersistableWallet.from(JSONObject(it)) }
override suspend fun putValue(
preferenceProvider: PreferenceProvider,
newValue: PersistableWallet?
) = preferenceProvider.putString(key, newValue?.toJson()?.toString())
}

View File

@ -0,0 +1,19 @@
package cash.z.ecc.android.sdk.demoapp.preference.api
import cash.z.ecc.android.sdk.demoapp.preference.model.entry.Key
import kotlinx.coroutines.flow.Flow
interface PreferenceProvider {
suspend fun hasKey(key: Key): Boolean
suspend fun putString(key: Key, value: String?)
suspend fun getString(key: Key): String?
/**
* @return Flow to observe potential changes to the value associated with the key in the preferences.
* Consumers of the flow will need to then query the value and determine whether it has changed.
*/
fun observe(key: Key): Flow<Unit>
}

View File

@ -0,0 +1,23 @@
package cash.z.ecc.android.sdk.demoapp.preference.model.entry
import cash.z.ecc.android.sdk.demoapp.preference.api.PreferenceProvider
data class BooleanPreferenceDefault(
override val key: Key,
private val defaultValue: Boolean
) : PreferenceDefault<Boolean> {
@Suppress("SwallowedException")
override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)?.let {
try {
it.toBooleanStrict()
} catch (e: IllegalArgumentException) {
// [TODO #32]: Log coercion failure instead of just silently returning default
defaultValue
}
} ?: defaultValue
override suspend fun putValue(preferenceProvider: PreferenceProvider, newValue: Boolean) {
preferenceProvider.putString(key, newValue.toString())
}
}

View File

@ -0,0 +1,22 @@
package cash.z.ecc.android.sdk.demoapp.preference.model.entry
import cash.z.ecc.android.sdk.demoapp.preference.api.PreferenceProvider
data class IntegerPreferenceDefault(
override val key: Key,
private val defaultValue: Int
) : PreferenceDefault<Int> {
override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)?.let {
try {
it.toInt()
} catch (e: NumberFormatException) {
// [TODO #32]: Log coercion failure instead of just silently returning default
defaultValue
}
} ?: defaultValue
override suspend fun putValue(preferenceProvider: PreferenceProvider, newValue: Int) {
preferenceProvider.putString(key, newValue.toString())
}
}

View File

@ -0,0 +1,34 @@
package cash.z.ecc.android.sdk.demoapp.preference.model.entry
/**
* Defines a preference key.
*
* Different preference providers may have unique restrictions on keys. This attempts to
* find a least common denominator with some reasonable limits on what the keys can contain.
*/
@JvmInline
value class Key(val key: String) {
init {
requireKeyConstraints(key)
}
companion object {
private const val MIN_KEY_LENGTH = 1
private const val MAX_KEY_LENGTH = 256
private val REGEX = Regex("[a-zA-Z0-9_]*") // $NON-NLS
/**
* Checks a preference key against known constraints.
*
* @param key Key to check.
*/
private fun requireKeyConstraints(key: String) {
require(key.length in 1..MAX_KEY_LENGTH) {
"Invalid key $key. Length (${key.length}) should be [$MIN_KEY_LENGTH, $MAX_KEY_LENGTH]." // $NON-NLS
}
require(REGEX.matches(key)) { "Invalid key $key. Key must contain only letter and numbers." } // $NON-NLS
}
}
}

View File

@ -0,0 +1,47 @@
package cash.z.ecc.android.sdk.demoapp.preference.model.entry
import cash.z.ecc.android.sdk.demoapp.preference.api.PreferenceProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* An entry represents a key and a default value for a preference. By using a Default object,
* multiple parts of the code can fetch the same preference without duplication or accidental
* variation in default value. Clients define the key and default value together, rather than just
* the key.
*/
/*
* API note: the default value is not available through the public interface in order to prevent
* clients from accidentally using the default value instead of the preference value.
*
* Implementation note: although primitives would be nice, Objects don't increase memory usage much.
* The autoboxing cache solves Booleans, and Strings are already objects, so that just leaves Integers.
* Overall the number of Integer preference entries is expected to be low compared to Booleans,
* and perhaps many Integer values will also fit within the autoboxing cache.
*/
interface PreferenceDefault<T> {
val key: Key
/**
* @param preferenceProvider Provides actual preference values.
* @return The value in the preference, or the default value if no preference exists.
*/
suspend fun getValue(preferenceProvider: PreferenceProvider): T
/**
* @param preferenceProvider Provides actual preference values.
* @param newValue New value to write.
*/
suspend fun putValue(preferenceProvider: PreferenceProvider, newValue: T)
/**
* @param preferenceProvider Provides actual preference values.
* @return Flow that emits preference changes. Note that implementations should emit an initial value
* indicating what was stored in the preferences, in addition to subsequent updates.
*/
fun observe(preferenceProvider: PreferenceProvider): Flow<T> = preferenceProvider.observe(key)
.map { getValue(preferenceProvider) }
.distinctUntilChanged()
}

View File

@ -0,0 +1,16 @@
package cash.z.ecc.android.sdk.demoapp.preference.model.entry
import cash.z.ecc.android.sdk.demoapp.preference.api.PreferenceProvider
data class StringPreferenceDefault(
override val key: Key,
private val defaultValue: String
) : PreferenceDefault<String> {
override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)
?: defaultValue
override suspend fun putValue(preferenceProvider: PreferenceProvider, newValue: String) {
preferenceProvider.putString(key, newValue)
}
}

View File

@ -0,0 +1,31 @@
package cash.z.ecc.android.sdk.demoapp.type
import android.content.Context
import cash.z.ecc.android.sdk.demoapp.R
import cash.z.ecc.android.sdk.model.ZcashNetwork
/*
* Note: If we end up having trouble with this implementation in the future, especially with the rollout
* of disabling transitive resources, we do have alternative implementations.
*
* Probably the most straightforward and high performance would be to implement an interface, have
* the Application class implement the interface, and allow this to cast the Application object to
* get the value. If the Application does not implement the interface, then the Mainnet can be the
* default.
*
* Alternatives include
* - Adding build variants to sdk-ext-lib, ui-lib, and app which gets complex. The current approach
* or the approach outlined above only requires build variants on the app module.
* - Using a ContentProvider for dynamic injection, where the URI is defined
* - Using AndroidManifest metadata for dynamic injection
*/
/**
* @return Zcash network determined from resources. A resource overlay of [R.bool.zcash_is_testnet]
* can be used for different build variants to change the network type.
*/
fun ZcashNetwork.Companion.fromResources(context: Context) =
if (context.resources.getBoolean(R.bool.zcash_is_testnet)) {
ZcashNetwork.Testnet
} else {
ZcashNetwork.Mainnet
}

View File

@ -0,0 +1,11 @@
package cash.z.ecc.android.sdk.demoapp.ui.common
import kotlin.time.Duration.Companion.seconds
// Recommended timeout for Android configuration changes to keep Kotlin Flow from restarting
val ANDROID_STATE_FLOW_TIMEOUT = 5.seconds
/**
* A tiny weight, useful for spacers to fill an empty space.
*/
const val MINIMAL_WEIGHT = 0.0001f

View File

@ -0,0 +1,58 @@
package cash.z.ecc.android.sdk.demoapp.ui.common
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
import kotlin.time.TimeSource
@OptIn(ExperimentalTime::class)
fun <T> Flow<T>.throttle(
duration: Duration,
timeSource: TimeSource = TimeSource.Monotonic
): Flow<T> = flow {
coroutineScope {
val context = coroutineContext
val mutex = Mutex()
var timeMark = timeSource.markNow()
var delayEmit: Deferred<Unit>? = null
var firstValue = true
var valueToEmit: T
collect { value ->
if (firstValue) {
firstValue = false
emit(value)
timeMark = timeSource.markNow()
return@collect
}
delayEmit?.cancel()
valueToEmit = value
if (timeMark.elapsedNow() >= duration) {
mutex.withLock {
emit(valueToEmit)
timeMark = timeSource.markNow()
}
} else {
delayEmit = async(Dispatchers.Default) {
mutex.withLock {
delay(duration)
withContext(context) {
emit(valueToEmit)
}
timeMark = timeSource.markNow()
}
}
}
}
}
}

View File

@ -0,0 +1,135 @@
package cash.z.ecc.android.sdk.demoapp.ui.screen.addresses.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.demoapp.R
import cash.z.ecc.android.sdk.demoapp.model.WalletAddresses
import kotlinx.coroutines.flow.flow
// @Preview
// @Composable
// fun ComposablePreview() {
// MaterialTheme {
// Addresses()
// }
// }
/**
* @param copyToClipboard First string is a tag, the second string is the text to copy.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Addresses(
synchronizer: Synchronizer,
copyToClipboard: (String, String) -> Unit,
onBack: () -> Unit
) {
Scaffold(topBar = {
AddressesTopAppBar(onBack)
}) { paddingValues ->
// TODO [#846]: Slow addresses providing
// TODO [#846]: https://github.com/zcash/zcash-android-wallet-sdk/issues/846
val walletAddresses = flow {
emit(WalletAddresses.new(synchronizer))
}.collectAsState(
initial = null
).value
if (null != walletAddresses) {
AddressesMainContent(
paddingValues = paddingValues,
addresses = walletAddresses,
copyToClipboard = copyToClipboard
)
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun AddressesTopAppBar(onBack: () -> Unit) {
TopAppBar(
title = { Text(text = stringResource(id = R.string.menu_address)) },
navigationIcon = {
IconButton(
onClick = onBack
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = null
)
}
}
)
}
@Composable
private fun AddressesMainContent(
paddingValues: PaddingValues,
addresses: WalletAddresses,
copyToClipboard: (String, String) -> Unit
) {
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(top = paddingValues.calculateTopPadding())
) {
Text(stringResource(id = R.string.unified_address))
addresses.unified.address.also { address ->
val tag = stringResource(id = R.string.unified_address)
Text(
address,
Modifier.clickable {
copyToClipboard(tag, address)
}
)
}
Spacer(Modifier.padding(8.dp))
Text(stringResource(id = R.string.sapling_address))
addresses.sapling.address.also { address ->
val tag = stringResource(id = R.string.sapling_address_tag)
Text(
address,
Modifier.clickable {
copyToClipboard(tag, address)
}
)
}
Spacer(Modifier.padding(8.dp))
Text(stringResource(id = R.string.transparent_address))
addresses.transparent.address.also { address ->
val tag = stringResource(id = R.string.transparent_address)
Text(
address,
Modifier.clickable {
copyToClipboard(tag, address)
}
)
}
}
}

View File

@ -0,0 +1,115 @@
package cash.z.ecc.android.sdk.demoapp.ui.screen.home.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.tooling.preview.Preview
import cash.z.ecc.android.sdk.demoapp.R
@Preview
@Composable
fun ComposablePreviewHome() {
MaterialTheme {
Home(
// WalletSnapshotFixture.new(),
goSend = {},
goAddressDetails = {},
resetSdk = {}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongParameterList")
@Composable
fun Home(
goSend: () -> Unit,
goAddressDetails: () -> Unit,
resetSdk: () -> Unit
) {
Scaffold(topBar = {
HomeTopAppBar(resetSdk)
}) { paddingValues ->
HomeMainContent(
paddingValues = paddingValues,
goSend = goSend,
goAddressDetails = goAddressDetails
)
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun HomeTopAppBar(
resetSdk: () -> Unit
) {
TopAppBar(
title = { Text(text = stringResource(id = R.string.app_name)) },
actions = {
DebugMenu(resetSdk = resetSdk)
}
)
}
@Composable
private fun DebugMenu(resetSdk: () -> Unit) {
var expanded by rememberSaveable { mutableStateOf(false) }
IconButton(onClick = { expanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = null)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text("Reset SDK") },
onClick = {
resetSdk()
expanded = false
}
)
}
}
@Composable
private fun HomeMainContent(
paddingValues: PaddingValues,
goSend: () -> Unit,
goAddressDetails: () -> Unit
) {
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(top = paddingValues.calculateTopPadding())
) {
Button(goSend) {
Text(text = stringResource(id = R.string.menu_send))
}
Button(goAddressDetails) {
Text(text = stringResource(id = R.string.menu_address))
}
}
}

View File

@ -0,0 +1,33 @@
package cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.demoapp.model.PercentDecimal
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
data class WalletSnapshot(
val status: Synchronizer.Status,
val processorInfo: CompactBlockProcessor.ProcessorInfo,
val orchardBalance: WalletBalance,
val saplingBalance: WalletBalance,
val transparentBalance: WalletBalance,
val pendingCount: Int,
val progress: PercentDecimal,
val synchronizerError: SynchronizerError?
) {
// Note: the wallet is effectively empty if it cannot cover the miner's fee
val hasFunds = saplingBalance.available.value >
(ZcashSdk.MINERS_FEE.value.toDouble() / Zatoshi.ZATOSHI_PER_ZEC) // 0.00001
val hasSaplingBalance = saplingBalance.total.value > 0
val isSendEnabled: Boolean get() = status == Synchronizer.Status.SYNCED && hasFunds
}
fun WalletSnapshot.totalBalance() = orchardBalance.total + saplingBalance.total + transparentBalance.total
// 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.
fun WalletSnapshot.spendableBalance() = orchardBalance.available + saplingBalance.available

View File

@ -0,0 +1,307 @@
package cash.z.ecc.android.sdk.demoapp.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.demoapp.WalletCoordinator
import cash.z.ecc.android.sdk.demoapp.getInstance
import cash.z.ecc.android.sdk.demoapp.model.PercentDecimal
import cash.z.ecc.android.sdk.demoapp.model.PersistableWallet
import cash.z.ecc.android.sdk.demoapp.model.WalletAddresses
import cash.z.ecc.android.sdk.demoapp.model.ZecSend
import cash.z.ecc.android.sdk.demoapp.model.send
import cash.z.ecc.android.sdk.demoapp.preference.EncryptedPreferenceKeys
import cash.z.ecc.android.sdk.demoapp.preference.EncryptedPreferenceSingleton
import cash.z.ecc.android.sdk.demoapp.ui.common.ANDROID_STATE_FLOW_TIMEOUT
import cash.z.ecc.android.sdk.demoapp.ui.common.throttle
import cash.z.ecc.android.sdk.demoapp.util.Twig
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.PendingTransaction
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.isMined
import cash.z.ecc.android.sdk.model.isSubmitSuccess
import cash.z.ecc.android.sdk.tool.DerivationTool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.seconds
import kotlin.time.ExperimentalTime
// To make this more multiplatform compatible, we need to remove the dependency on Context
// for loading the preferences.
class WalletViewModel(application: Application) : AndroidViewModel(application) {
private val walletCoordinator = WalletCoordinator.getInstance(application)
/*
* Using the Mutex may be overkill, but it ensures that if multiple calls are accidentally made
* that they have a consistent ordering.
*/
private val persistWalletMutex = Mutex()
/**
* Synchronizer that is retained long enough to survive configuration changes.
*/
val synchronizer = walletCoordinator.synchronizer.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
val secretState: StateFlow<SecretState> = walletCoordinator.persistableWallet
.map { persistableWallet ->
Twig.info { "Here" }
if (null == persistableWallet) {
SecretState.None
} else {
SecretState.Ready(persistableWallet)
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
SecretState.Loading
)
val spendingKey = secretState
.filterIsInstance<SecretState.Ready>()
.map { it.persistableWallet }
.map {
val bip39Seed = withContext(Dispatchers.IO) {
Mnemonics.MnemonicCode(it.seedPhrase.joinToString()).toSeed()
}
DerivationTool.deriveUnifiedSpendingKey(
seed = bip39Seed,
network = it.network,
account = Account.DEFAULT
)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class)
val walletSnapshot: StateFlow<WalletSnapshot?> = synchronizer
.flatMapLatest {
if (null == it) {
flowOf(null)
} else {
it.toWalletSnapshot()
}
}
.throttle(1.seconds)
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
val addresses: StateFlow<WalletAddresses?> = synchronizer
.filterNotNull()
.map {
WalletAddresses.new(it)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
/**
* Creates a wallet asynchronously and then persists it. Clients observe
* [secretState] to see the side effects. This would be used for a user creating a new wallet.
*/
/*
* Although waiting for the wallet to be written and then read back is slower, it is probably
* safer because it 1. guarantees the wallet is written to disk and 2. has a single source of truth.
*/
fun persistNewWallet() {
val application = getApplication<Application>()
viewModelScope.launch {
val newWallet = PersistableWallet.new(application)
persistExistingWallet(newWallet)
}
}
/**
* Persists a wallet asynchronously. Clients observe [secretState]
* to see the side effects. This would be used for a user restoring a wallet from a backup.
*/
fun persistExistingWallet(persistableWallet: PersistableWallet) {
val application = getApplication<Application>()
viewModelScope.launch {
val preferenceProvider = EncryptedPreferenceSingleton.getInstance(application)
persistWalletMutex.withLock {
EncryptedPreferenceKeys.PERSISTABLE_WALLET.putValue(preferenceProvider, persistableWallet)
}
}
}
/**
* Asynchronously sends funds. This operation
*/
fun send(zecSend: ZecSend) {
// Note that if synchronizer is null this will silently fail
val synchronizer = synchronizer.value
val spendingKey = spendingKey.value
if (null != synchronizer && null != spendingKey) {
viewModelScope.launch {
synchronizer.send(spendingKey, zecSend)
}
}
}
/**
* This method only has an effect if the synchronizer currently is loaded.
*/
fun rescanBlockchain() {
viewModelScope.launch {
walletCoordinator.rescanBlockchain()
}
}
/**
* This asynchronously resets the SDK state. This is non-destructive, as SDK state can be rederived.
*
* This could be used as a troubleshooting step in debugging.
*/
fun resetSdk() {
walletCoordinator.resetSdk()
}
}
/**
* Represents the state of the wallet secret.
*/
sealed class SecretState {
object Loading : SecretState()
object None : SecretState()
class Ready(val persistableWallet: PersistableWallet) : SecretState()
}
/**
* Represents all kind of Synchronizer errors
*/
// TODO [#529] https://github.com/zcash/secant-android-wallet/issues/529
sealed class SynchronizerError {
abstract fun getCauseMessage(): String?
class Critical(val error: Throwable?) : SynchronizerError() {
override fun getCauseMessage(): String? = error?.localizedMessage
}
class Processor(val error: Throwable?) : SynchronizerError() {
override fun getCauseMessage(): String? = error?.localizedMessage
}
class Submission(val error: Throwable?) : SynchronizerError() {
override fun getCauseMessage(): String? = error?.localizedMessage
}
class Setup(val error: Throwable?) : SynchronizerError() {
override fun getCauseMessage(): String? = error?.localizedMessage
}
class Chain(val x: BlockHeight, val y: BlockHeight) : SynchronizerError() {
override fun getCauseMessage(): String = "$x, $y"
}
}
private fun Synchronizer.toCommonError(): Flow<SynchronizerError?> = callbackFlow {
// just for initial default value emit
trySend(null)
onCriticalErrorHandler = {
Twig.error { "WALLET - Error Critical: $it" }
trySend(SynchronizerError.Critical(it))
false
}
onProcessorErrorHandler = {
Twig.error { "WALLET - Error Processor: $it" }
trySend(SynchronizerError.Processor(it))
false
}
onSubmissionErrorHandler = {
Twig.error { "WALLET - Error Submission: $it" }
trySend(SynchronizerError.Submission(it))
false
}
onSetupErrorHandler = {
Twig.error { "WALLET - Error Setup: $it" }
trySend(SynchronizerError.Setup(it))
false
}
onChainErrorHandler = { x, y ->
Twig.error { "WALLET - Error Chain: $x, $y" }
trySend(SynchronizerError.Chain(x, y))
}
awaitClose {
// nothing to close here
}
}
// No good way around needing magic numbers for the indices
@Suppress("MagicNumber")
private fun Synchronizer.toWalletSnapshot() =
combine(
status, // 0
processorInfo, // 1
orchardBalances, // 2
saplingBalances, // 3
transparentBalances, // 4
pendingTransactions.distinctUntilChanged(), // 5
progress, // 6
toCommonError() // 7
) { flows ->
val pendingCount = (flows[5] as List<*>)
.filterIsInstance(PendingTransaction::class.java)
.count {
it.isSubmitSuccess() && !it.isMined()
}
val orchardBalance = flows[2] as WalletBalance?
val saplingBalance = flows[3] as WalletBalance?
val transparentBalance = flows[4] as WalletBalance?
val progressPercentDecimal = (flows[6] as Int).let { value ->
if (value > PercentDecimal.MAX || value < PercentDecimal.MIN) {
PercentDecimal.ZERO_PERCENT
}
PercentDecimal((flows[6] as Int) / 100f)
}
WalletSnapshot(
flows[0] as Synchronizer.Status,
flows[1] as CompactBlockProcessor.ProcessorInfo,
orchardBalance ?: WalletBalance(Zatoshi(0), Zatoshi(0)),
saplingBalance ?: WalletBalance(Zatoshi(0), Zatoshi(0)),
transparentBalance ?: WalletBalance(Zatoshi(0), Zatoshi(0)),
pendingCount,
progressPercentDecimal,
flows[7] as SynchronizerError?
)
}

View File

@ -0,0 +1,97 @@
package cash.z.ecc.android.sdk.demoapp.ui.screen.seed.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.demoapp.R
import cash.z.ecc.android.sdk.demoapp.fixture.WalletFixture
import cash.z.ecc.android.sdk.demoapp.model.PersistableWallet
import cash.z.ecc.android.sdk.demoapp.model.SeedPhrase
import cash.z.ecc.android.sdk.model.ZcashNetwork
@Preview
@Composable
fun ComposablePreview() {
MaterialTheme {
Seed(
ZcashNetwork.Mainnet,
onExistingWallet = {},
onNewWallet = {}
)
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun Seed(
zcashNetwork: ZcashNetwork,
onExistingWallet: (PersistableWallet) -> Unit,
onNewWallet: () -> Unit
) {
Scaffold(topBar = {
ConfigureSeedTopAppBar()
}) { paddingValues ->
ConfigureSeedMainContent(
paddingValues = paddingValues,
zcashNetwork = zcashNetwork,
onExistingWallet = onExistingWallet,
onNewWallet = onNewWallet
)
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun ConfigureSeedTopAppBar() {
TopAppBar(
title = { Text(text = stringResource(id = R.string.configure_seed)) }
)
}
@Composable
private fun ConfigureSeedMainContent(
paddingValues: PaddingValues,
zcashNetwork: ZcashNetwork,
onExistingWallet: (PersistableWallet) -> Unit,
onNewWallet: () -> Unit
) {
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(top = paddingValues.calculateTopPadding())
) {
Button(
onClick = {
val newWallet = PersistableWallet(zcashNetwork, null, SeedPhrase.new(WalletFixture.Alice.seedPhrase))
onExistingWallet(newWallet)
}
) {
Text(text = stringResource(id = R.string.person_alyssa))
}
Button(
onClick = {
val newWallet = PersistableWallet(zcashNetwork, null, SeedPhrase.new(WalletFixture.Alice.seedPhrase))
onExistingWallet(newWallet)
}
) {
Text(text = stringResource(R.string.person_ben))
}
Button(
onClick = onNewWallet
) {
Text(text = stringResource(id = R.string.seed_random))
}
}
}

View File

@ -0,0 +1,240 @@
package cash.z.ecc.android.sdk.demoapp.ui.screen.send.view
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
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.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import cash.z.ecc.android.sdk.demoapp.R
import cash.z.ecc.android.sdk.demoapp.fixture.WalletFixture
import cash.z.ecc.android.sdk.demoapp.model.Memo
import cash.z.ecc.android.sdk.demoapp.model.MonetarySeparators
import cash.z.ecc.android.sdk.demoapp.model.ZecSend
import cash.z.ecc.android.sdk.demoapp.model.ZecSendExt
import cash.z.ecc.android.sdk.demoapp.model.ZecString
import cash.z.ecc.android.sdk.demoapp.model.ZecStringExt
import cash.z.ecc.android.sdk.demoapp.model.toZecString
import cash.z.ecc.android.sdk.demoapp.type.fromResources
import cash.z.ecc.android.sdk.demoapp.ui.common.MINIMAL_WEIGHT
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel.WalletSnapshot
import cash.z.ecc.android.sdk.model.ZcashNetwork
// @Preview
// @Composable
// fun ComposablePreview() {
// MaterialTheme {
// Send()
// }
// }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Send(
walletSnapshot: WalletSnapshot,
onSend: (ZecSend) -> Unit,
onBack: () -> Unit
) {
Scaffold(topBar = {
SendTopAppBar(onBack)
}) { paddingValues ->
SendMainContent(
paddingValues = paddingValues,
walletSnapshot = walletSnapshot,
onSend = onSend
)
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun SendTopAppBar(onBack: () -> Unit) {
TopAppBar(
title = { Text(text = stringResource(id = R.string.menu_send)) },
navigationIcon = {
IconButton(
onClick = onBack
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = null
)
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Suppress("LongMethod")
private fun SendMainContent(
paddingValues: PaddingValues,
walletSnapshot: WalletSnapshot,
onSend: (ZecSend) -> Unit
) {
val context = LocalContext.current
val monetarySeparators = MonetarySeparators.current()
val allowedCharacters = ZecString.allowedCharacters(monetarySeparators)
var amountZecString by rememberSaveable {
mutableStateOf("")
}
var recipientAddressString by rememberSaveable {
mutableStateOf("")
}
var memoString by rememberSaveable { mutableStateOf("") }
var validation by rememberSaveable {
mutableStateOf<Set<ZecSendExt.ZecSendValidation.Invalid.ValidationError>>(emptySet())
}
Column(
Modifier
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(top = paddingValues.calculateTopPadding())
) {
Text(text = stringResource(id = R.string.send_available_balance))
Row(Modifier.fillMaxWidth()) {
Text(text = walletSnapshot.saplingBalance.available.toZecString())
}
TextField(
value = amountZecString,
onValueChange = { newValue ->
if (!ZecStringExt.filterContinuous(context, monetarySeparators, newValue)) {
return@TextField
}
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_address)) }
)
val zcashNetwork = ZcashNetwork.fromResources(context)
Column(
Modifier
.fillMaxWidth()
) {
// Alice's addresses
Row(
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
) {
Button({ recipientAddressString = WalletFixture.Alice.getAddresses(zcashNetwork).unified }) {
Text(text = stringResource(id = R.string.send_alyssa_unified))
}
Spacer(Modifier.size(8.dp))
Button({ recipientAddressString = WalletFixture.Alice.getAddresses(zcashNetwork).sapling }) {
Text(text = stringResource(id = R.string.send_alyssa_sapling))
}
Spacer(Modifier.size(8.dp))
Button({ recipientAddressString = WalletFixture.Alice.getAddresses(zcashNetwork).transparent }) {
Text(text = stringResource(id = R.string.send_alyssa_transparent))
}
}
// Bob's addresses
Row(
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
) {
Button({ recipientAddressString = WalletFixture.Bob.getAddresses(zcashNetwork).unified }) {
Text(text = stringResource(id = R.string.send_ben_unified))
}
Spacer(Modifier.size(8.dp))
Button({ recipientAddressString = WalletFixture.Bob.getAddresses(zcashNetwork).sapling }) {
Text(text = stringResource(id = R.string.send_ben_sapling))
}
Spacer(Modifier.size(8.dp))
Button({ recipientAddressString = WalletFixture.Bob.getAddresses(zcashNetwork).transparent }) {
Text(text = stringResource(id = R.string.send_ben_transparent))
}
}
}
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(", "))
}
Button(
onClick = {
val zecSendValidation = ZecSendExt.new(
context,
recipientAddressString,
amountZecString,
memoString,
monetarySeparators
)
when (zecSendValidation) {
is ZecSendExt.ZecSendValidation.Valid -> onSend(zecSendValidation.zecSend)
is ZecSendExt.ZecSendValidation.Invalid -> validation = zecSendValidation.validationErrors
}
},
// Needs actual validation
enabled = amountZecString.isNotBlank() && recipientAddressString.isNotBlank()
) {
Text(stringResource(id = R.string.send_button))
}
}
}

View File

@ -0,0 +1,40 @@
package cash.z.ecc.android.sdk.demoapp.util
import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.annotation.IntRange
object AndroidApiVersion {
/**
* @param sdk SDK version number to test against the current environment.
* @return `true` if [android.os.Build.VERSION.SDK_INT] is greater than or equal to
* [sdk].
*/
@ChecksSdkIntAtLeast(parameter = 0)
fun isAtLeast(@IntRange(from = Build.VERSION_CODES.BASE.toLong()) sdk: Int): Boolean {
return Build.VERSION.SDK_INT >= sdk
}
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.N)
val isAtLeastN = isAtLeast(Build.VERSION_CODES.N)
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
val isAtLeastO = isAtLeast(Build.VERSION_CODES.O)
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.P)
val isAtLeastP = isAtLeast(Build.VERSION_CODES.P)
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q)
val isAtLeastQ = isAtLeast(Build.VERSION_CODES.Q)
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R)
val isAtLeastR = isAtLeast(Build.VERSION_CODES.R)
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
val isAtLeastS = isAtLeast(Build.VERSION_CODES.S)
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU)
val isAtLeastT = isAtLeast(Build.VERSION_CODES.TIRAMISU)
val isPreview = 0 != Build.VERSION.PREVIEW_SDK_INT
}

View File

@ -0,0 +1,33 @@
package cash.z.ecc.android.sdk.demoapp.util
/**
* Implements a lazy singleton pattern with an input argument.
*
* This class is thread-safe.
*/
class LazyWithArgument<in Input, out Output>(private val deferredCreator: ((Input) -> Output)) {
@Volatile
private var singletonInstance: Output? = null
private val intrinsicLock = Any()
fun getInstance(input: Input): Output {
/*
* Double-checked idiom for lazy initialization, Effective Java 2nd edition page 283.
*/
var localSingletonInstance = singletonInstance
if (null == localSingletonInstance) {
synchronized(intrinsicLock) {
localSingletonInstance = singletonInstance
if (null == localSingletonInstance) {
localSingletonInstance = deferredCreator(input)
singletonInstance = localSingletonInstance
}
}
}
return localSingletonInstance!!
}
}

View File

@ -1,63 +0,0 @@
package cash.z.ecc.android.sdk.demoapp.util
import android.content.Context
@Deprecated(
message = "Do not use this! It is insecure and only intended for demo purposes to " +
"show how to bridge to an existing key storage mechanism. Instead, use the Android " +
"Keystore system or a 3rd party library that leverages it."
)
class SampleStorage(context: Context) {
private val prefs =
context.applicationContext.getSharedPreferences("ExtremelyInsecureStorage", Context.MODE_PRIVATE)
fun saveSensitiveString(key: String, value: String) {
prefs.edit().putString(key, value).apply()
}
fun loadSensitiveString(key: String): String? = prefs.getString(key, null)
fun saveSensitiveBytes(key: String, value: ByteArray) {
saveSensitiveString(key, value.toString(Charsets.ISO_8859_1))
}
fun loadSensitiveBytes(key: String): ByteArray? =
prefs.getString(key, null)?.toByteArray(Charsets.ISO_8859_1)
}
/**
* Simple demonstration of how to take existing code that securely stores data and bridge it into
* the SDK. This class delegates to the storage object. For demo purposes, we're using an insecure
* SampleStorage implementation but this can easily be swapped for a truly secure storage solution.
*/
@Suppress("deprecation")
class SampleStorageBridge(context: Context) {
private val delegate = SampleStorage(context.applicationContext)
/**
* Just a sugar method to help with being explicit in sample code. We want to show developers
* our intention that they write simple bridges to secure storage components.
*/
fun securelyStoreSeed(seed: ByteArray): SampleStorageBridge {
delegate.saveSensitiveBytes(KEY_SEED, seed)
return this
}
/**
* Just a sugar method to help with being explicit in sample code. We want to show developers
* our intention that they write simple bridges to secure storage components.
*/
fun securelyStorePrivateKey(key: String): SampleStorageBridge {
delegate.saveSensitiveString(KEY_PK, key)
return this
}
val seed: ByteArray get() = delegate.loadSensitiveBytes(KEY_SEED)!!
val key get() = delegate.loadSensitiveString(KEY_PK)!!
companion object {
private const val KEY_SEED = "cash.z.ecc.android.sdk.demoapp.SEED"
private const val KEY_PK = "cash.z.ecc.android.sdk.demoapp.PK"
}
}

View File

@ -1,27 +0,0 @@
package cash.z.ecc.android.sdk.demoapp.util
import cash.z.android.plugin.MnemonicPlugin
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.Mnemonics.MnemonicCode
import cash.z.ecc.android.bip39.Mnemonics.WordCount
import cash.z.ecc.android.bip39.toEntropy
import cash.z.ecc.android.bip39.toSeed
import java.util.Locale
/**
* A sample implementation of a plugin for handling Mnemonic phrases. Any library can easily be
* plugged into the SDK in this manner. In this case, we are wrapping a few example 3rd party
* libraries with a thin layer that converts from their API to ours via the MnemonicPlugin
* interface. We do not endorse these libraries, rather we just use them as an example of how to
* take existing infrastructure and plug it into the SDK.
*/
class SimpleMnemonics : MnemonicPlugin {
override fun fullWordList(languageCode: String) = Mnemonics.getCachedWords(Locale.ENGLISH.language)
override fun nextEntropy(): ByteArray = WordCount.COUNT_24.toEntropy()
override fun nextMnemonic(): CharArray = MnemonicCode(WordCount.COUNT_24).chars
override fun nextMnemonic(seed: ByteArray): CharArray = MnemonicCode(seed).chars
override fun nextMnemonicList(): List<CharArray> = MnemonicCode(WordCount.COUNT_24).words
override fun nextMnemonicList(seed: ByteArray): List<CharArray> = MnemonicCode(seed).words
override fun toSeed(mnemonic: CharArray): ByteArray = MnemonicCode(mnemonic).toSeed()
override fun toWordList(mnemonic: CharArray): List<CharArray> = MnemonicCode(mnemonic).words
}

View File

@ -0,0 +1,28 @@
package cash.z.ecc.android.sdk.demoapp.util
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Implements a coroutines-friendly lazy singleton pattern with an input argument.
*
* This class is thread-safe.
*/
class SuspendingLazy<in Input, out Output>(private val deferredCreator: suspend ((Input) -> Output)) {
private var singletonInstance: Output? = null
private val mutex = Mutex()
suspend fun getInstance(input: Input): Output {
mutex.withLock {
singletonInstance?.let {
return it
}
val newInstance = deferredCreator(input)
singletonInstance = newInstance
return newInstance
}
}
}

View File

@ -0,0 +1,180 @@
package cash.z.ecc.android.sdk.demoapp.util
import android.app.ActivityManager
import android.app.Application
import android.content.Context
import android.os.Build
import android.os.Process
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import java.util.Locale
/**
* A twig is a tiny log. These logs are intended for development rather than for high performance
* or usage in production.
*/
@Suppress("TooManyFunctions")
object Twig {
/**
* Format string for log messages.
*
* The format is: <Process> <Thread> <Class>.<method>(): <message>
*/
private const val FORMAT = "%-27s %-30s %s.%s(): %s" // $NON-NLS-1$
@Volatile
private var tag: String = "Twig"
@Volatile
private var processName: String = ""
/**
* For best results, call this method before trying to log messages.
*/
fun initialize(context: Context) {
tag = getApplicationName(context)
processName = searchForProcessName(context) ?: "Unknown"
}
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun verbose(message: () -> String) {
Log.v(tag, formatMessage(message))
}
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun verbose(throwable: Throwable, message: () -> String) {
Log.v(tag, formatMessage(message), throwable)
}
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun debug(message: () -> String) {
Log.d(tag, formatMessage(message))
}
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun debug(throwable: Throwable, message: () -> String) {
Log.d(tag, formatMessage(message), throwable)
}
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun info(message: () -> String) {
Log.i(tag, formatMessage(message))
}
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun info(throwable: Throwable, message: () -> String) {
Log.i(tag, formatMessage(message), throwable)
}
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun warn(message: () -> String) {
Log.w(tag, formatMessage(message))
}
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun warn(throwable: Throwable, message: () -> String) {
Log.w(tag, formatMessage(message), throwable)
}
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun error(message: () -> String) {
Log.e(tag, formatMessage(message))
}
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun error(throwable: Throwable, message: () -> String) {
Log.e(tag, formatMessage(message), throwable)
}
/**
* Can be called in a release build to test that `assumenosideeffects` ProGuard rules have been
* properly processed to strip out logging messages.
*/
// JVMStatic is to simplify ProGuard/R8 rules for stripping this
@JvmStatic
fun assertLoggingStripped() {
@Suppress("MaxLineLength")
throw AssertionError("Logging was not disabled by ProGuard or R8. Logging should be disabled in release builds to reduce risk of sensitive information being leaked.") // $NON-NLS-1$
}
private const val CALL_DEPTH = 4
private fun formatMessage(message: () -> String): String {
val currentThread = Thread.currentThread()
val trace = currentThread.stackTrace
val sourceClass = trace[CALL_DEPTH].className
val sourceMethod = trace[CALL_DEPTH].methodName
return String.format(
Locale.ROOT,
FORMAT,
processName,
currentThread.name,
cleanupClassName(sourceClass),
sourceMethod,
message()
)
}
}
/**
* Gets the name of the application or the package name if the application has no name.
*
* @param context Application context.
* @return Label of the application from the Android Manifest or the package name if no label
* was set.
*/
fun getApplicationName(context: Context): String {
val applicationLabel = context.packageManager.getApplicationLabel(context.applicationInfo)
return applicationLabel.toString().lowercase(Locale.ROOT).replace(" ", "-")
}
private fun cleanupClassName(classNameString: String): String {
val outerClassName = classNameString.substringBefore('$')
val simplerOuterClassName = outerClassName.substringAfterLast('.')
return if (simplerOuterClassName.isEmpty()) {
classNameString
} else {
simplerOuterClassName.removeSuffix("Kt")
}
}
/**
* @param context Application context.
* @return Name of the current process. May return null if a failure occurs, which is possible
* due to some race conditions in Android.
*/
private fun searchForProcessName(context: Context): String? {
return if (AndroidApiVersion.isAtLeastP) {
getProcessNamePPlus()
} else {
searchForProcessNameLegacy(context)
}
}
@RequiresApi(api = Build.VERSION_CODES.P)
private fun getProcessNamePPlus() = Application.getProcessName()
/**
* @param context Application context.
* @return Name of the current process. May return null if a failure occurs, which is possible
* due to some race conditions in older versions of Android.
*/
@VisibleForTesting
internal fun searchForProcessNameLegacy(context: Context): String? {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
return activityManager.runningAppProcesses?.find { Process.myPid() == it.pid }?.processName
}

View File

@ -1,10 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<!-- Note: We hide entire group on SDK sync related screens -->
<group
android:id="@+id/main_menu_group">
<group android:id="@+id/main_menu_group">
<item
android:id="@+id/action_settings"
android:orderInCategory="100"
@ -21,4 +19,9 @@
android:title="@string/action_reset_sdk"
app:showAsAction="never" />
</group>
<item
android:id="@+id/action_new_ui"
android:orderInCategory="100"
android:title="@string/action_new_ui"
app:showAsAction="never" />
</menu>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Resource that can be overlaid downstream with build variants to support
testnet builds. -->
<bool name="zcash_is_testnet">false</bool>
</resources>

View File

@ -0,0 +1,4 @@
<resources xmlns:tools="http://schemas.android.com/tools" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="co_electriccoin_zcash_zec_amount_regex_continuous_filter" formatted="true" tools:ignore="TypographyDashes">^([0-9]*([0-9]+([<xliff:g id="group" example=",">%1$s</xliff:g>]$|[<xliff:g id="group" example=",">%1$s</xliff:g>][0-9]+))*([<xliff:g id="dec" example=".">%2$s</xliff:g>]$|[<xliff:g id="dec" example=".">%2$s</xliff:g>][0-9]+)?)?$</string>
<string name="co_electriccoin_zcash_zec_amount_regex_confirm_filter" formatted="true" tools:ignore="TypographyDashes">^([0-9]{1,3}(?:[<xliff:g id="group" example=",">%1$s</xliff:g>]?[0-9]{3})*)*(?:[0-9]*[<xliff:g id="group" example=".">%2$s</xliff:g>][0-9]*)?$</string>
</resources>

View File

@ -8,6 +8,8 @@
<string name="action_faucet">Testnet Faucet</string>
<string name="action_reset_sdk">Reset SDK</string>
<string name="action_new_ui">New UI</string>
<!-- Drawer Menu -->
<string name="menu_home">Home</string>
<string name="menu_private_key">Get Private Key</string>
@ -31,4 +33,29 @@
<string name="sapling_address">Sapling address</string>
<string name="transparent_address">Transparent address</string>
<string name="action_shield">Shield Funds</string>
<string name="unified_address_tag">Zcash unified address</string>
<string name="sapling_address_tag">Zcash sapling address</string>
<string name="transparent_address_tag">Zcash transparent address</string>
<string name="configure_seed">Please select your wallet secret phrase</string>
<string name="person_alyssa">Alyssa P. Hacker</string>
<string name="person_ben">Ben Bitdiddle</string>
<string name="seed_custom">Type in a secret phrase</string>
<string name="seed_random">Generate a new random secret phrase</string>
<string name="send_amount">Amount</string>
<string name="send_alyssa_unified">Alyssas unified address</string>
<string name="send_alyssa_sapling">Alyssas sapling address</string>
<string name="send_alyssa_transparent">Alyssas transparent address</string>
<string name="send_ben_unified">Bens unified address</string>
<string name="send_ben_sapling">Bens sapling address</string>
<string name="send_ben_transparent">Bens transparent address</string>
<string name="send_available_balance">Available Sapling balance: </string>
<string name="send_to_address">Destination address</string>
<string name="send_memo">Memo</string>
<string name="send_button">Send</string>
</resources>

View File

@ -86,19 +86,25 @@ KSP_VERSION=1.7.21-1.0.8
PROTOBUF_GRADLE_PLUGIN_VERSION=0.8.19
RUST_GRADLE_PLUGIN_VERSION=0.9.3
ANDROIDX_ACTIVITY_VERSION=1.6.1
ANDROIDX_ANNOTATION_VERSION=1.5.0
ANDROIDX_APPCOMPAT_VERSION=1.5.1
ANDROIDX_COMPOSE_COMPILER_VERSION=1.4.0-alpha02
ANDROIDX_COMPOSE_MATERIAL3_VERSION=1.1.0-alpha02
ANDROIDX_COMPOSE_VERSION=1.3.1
ANDROIDX_CONSTRAINT_LAYOUT_VERSION=2.1.4
ANDROIDX_CORE_VERSION=1.9.0
ANDROIDX_DATABASE_VERSION=2.2.0
ANDROIDX_ESPRESSO_VERSION=3.5.0
ANDROIDX_LIFECYCLE_VERSION=2.5.1
ANDROIDX_LIFECYCLE_VERSION=2.6.0-alpha03
ANDROIDX_MULTIDEX_VERSION=2.0.1
ANDROIDX_NAVIGATION_VERSION=2.5.3
ANDROIDX_NAVIGATION_COMPOSE_VERSION=2.5.3
ANDROIDX_NAVIGATION_FRAGMENT_VERSION=2.4.2
ANDROIDX_PAGING_VERSION=2.1.2
ANDROIDX_PROFILE_INSTALLER_VERSION=1.3.0-alpha02
ANDROIDX_ROOM_VERSION=2.4.3
ANDROIDX_SECURITY_CRYPTO_VERSION=1.1.0-alpha04
ANDROIDX_TEST_JUNIT_VERSION=1.1.4
ANDROIDX_TEST_MACROBENCHMARK_VERSION=1.2.0-alpha08
ANDROIDX_TEST_ORCHESTRATOR_VERSION=1.4.2
@ -115,6 +121,7 @@ JACOCO_VERSION=0.8.8
JAVAX_ANNOTATION_VERSION=1.3.2
JUNIT_VERSION=5.9.1
KOTLINX_COROUTINES_VERSION=1.6.4
KOTLINX_DATETIME_VERSION=0.4.0
KOTLIN_VERSION=1.7.21
MOCKITO_KOTLIN_VERSION=2.2.0
MOCKITO_VERSION=4.9.0

View File

@ -65,8 +65,12 @@ dependencyResolutionManagement {
versionCatalogs {
create("libs") {
val androidGradlePluginVersion = extra["ANDROID_GRADLE_PLUGIN_VERSION"].toString()
val androidxActivityVersion = extra["ANDROIDX_ACTIVITY_VERSION"].toString()
val androidxAnnotationVersion = extra["ANDROIDX_ANNOTATION_VERSION"].toString()
val androidxAppcompatVersion = extra["ANDROIDX_APPCOMPAT_VERSION"].toString()
val androidxComposeCompilerVersion = extra["ANDROIDX_COMPOSE_COMPILER_VERSION"].toString()
val androidxComposeMaterial3Version = extra["ANDROIDX_COMPOSE_MATERIAL3_VERSION"].toString()
val androidxComposeVersion = extra["ANDROIDX_COMPOSE_VERSION"].toString()
val androidxConstraintLayoutVersion = extra["ANDROIDX_CONSTRAINT_LAYOUT_VERSION"].toString()
val androidxCoreVersion = extra["ANDROIDX_CORE_VERSION"].toString()
val androidxDatabaseVersion = extra["ANDROIDX_DATABASE_VERSION"].toString()
@ -74,10 +78,12 @@ dependencyResolutionManagement {
val androidxLifecycleVersion = extra["ANDROIDX_LIFECYCLE_VERSION"].toString()
val androidxMultidexVersion = extra["ANDROIDX_MULTIDEX_VERSION"].toString()
val androidxNavigationVersion = extra["ANDROIDX_NAVIGATION_VERSION"].toString()
val androidxNavigationComposeVersion = extra["ANDROIDX_NAVIGATION_COMPOSE_VERSION"].toString()
val androidxNavigationFragmentVersion = extra["ANDROIDX_NAVIGATION_FRAGMENT_VERSION"].toString()
val androidxPagingVersion = extra["ANDROIDX_PAGING_VERSION"].toString()
val androidxProfileInstallerVersion = extra["ANDROIDX_PROFILE_INSTALLER_VERSION"].toString()
val androidxRoomVersion = extra["ANDROIDX_ROOM_VERSION"].toString()
val androidxSecurityCryptoVersion = extra["ANDROIDX_SECURITY_CRYPTO_VERSION"].toString()
val androidxTestJunitVersion = extra["ANDROIDX_TEST_JUNIT_VERSION"].toString()
val androidxTestMacrobenchmarkVersion = extra["ANDROIDX_TEST_MACROBENCHMARK_VERSION"].toString()
val androidxTestOrchestratorVersion = extra["ANDROIDX_TEST_ORCHESTRATOR_VERSION"].toString()
@ -96,6 +102,7 @@ dependencyResolutionManagement {
val junitVersion = extra["JUNIT_VERSION"].toString()
val kotlinVersion = extra["KOTLIN_VERSION"].toString()
val kotlinxCoroutinesVersion = extra["KOTLINX_COROUTINES_VERSION"].toString()
val kotlinxDateTimeVersion = extra["KOTLINX_DATETIME_VERSION"].toString()
val mockitoKotlinVersion = extra["MOCKITO_KOTLIN_VERSION"].toString()
val mockitoVersion = extra["MOCKITO_VERSION"].toString()
val protocVersion = extra["PROTOC_VERSION"].toString()
@ -121,12 +128,15 @@ dependencyResolutionManagement {
// Libraries
library("androidx-annotation", "androidx.annotation:annotation:$androidxAnnotationVersion")
library("androidx-activity-compose", "androidx.activity:activity-compose:$androidxActivityVersion")
library("androidx-appcompat", "androidx.appcompat:appcompat:$androidxAppcompatVersion")
library("androidx-constraintlayout", "androidx.constraintlayout:constraintlayout:$androidxConstraintLayoutVersion")
library("androidx-core", "androidx.core:core-ktx:$androidxCoreVersion")
library("androidx-lifecycle-common", "androidx.lifecycle:lifecycle-common-java8:$androidxLifecycleVersion")
library("androidx-lifecycle-compose", "androidx.lifecycle:lifecycle-runtime-compose:$androidxLifecycleVersion")
library("androidx-lifecycle-runtime", "androidx.lifecycle:lifecycle-runtime-ktx:$androidxLifecycleVersion")
library("androidx-multidex", "androidx.multidex:multidex:$androidxMultidexVersion")
library("androidx-navigation-compose", "androidx.navigation:navigation-compose:$androidxNavigationComposeVersion")
library("androidx-navigation-fragment", "androidx.navigation:navigation-fragment-ktx:$androidxNavigationFragmentVersion")
library("androidx-navigation-ui", "androidx.navigation:navigation-ui-ktx:$androidxNavigationVersion")
library("androidx-paging", "androidx.paging:paging-runtime-ktx:$androidxPagingVersion")
@ -135,6 +145,7 @@ dependencyResolutionManagement {
library("androidx-room-core", "androidx.room:room-ktx:$androidxRoomVersion")
library("androidx-sqlite", "androidx.sqlite:sqlite-ktx:${androidxDatabaseVersion}")
library("androidx-sqlite-framework", "androidx.sqlite:sqlite-framework:${androidxDatabaseVersion}")
library("androidx-viewmodel-compose", "androidx.lifecycle:lifecycle-viewmodel-compose:$androidxLifecycleVersion")
library("bip39", "cash.z.ecc.android:kotlin-bip39:$bip39Version")
library("grpc-android", "io.grpc:grpc-android:$grpcVersion")
library("grpc-okhttp", "io.grpc:grpc-okhttp:$grpcVersion")
@ -147,9 +158,21 @@ dependencyResolutionManagement {
library("kotlin-stdlib", "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
library("kotlinx-coroutines-android", "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion")
library("kotlinx-coroutines-core", "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinxCoroutinesVersion")
library("kotlinx-datetime", "org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion")
library("material", "com.google.android.material:material:$googleMaterialVersion")
library("zcashwalletplgn", "com.github.zcash:zcash-android-wallet-plugins:$zcashWalletPluginVersion")
// Demo app
library("androidx-compose-foundation", "androidx.compose.foundation:foundation:$androidxComposeVersion")
library("androidx-compose-material3", "androidx.compose.material3:material3:$androidxComposeMaterial3Version")
library("androidx-compose-material-icons-core", "androidx.compose.material:material-icons-core:$androidxComposeVersion")
library("androidx-compose-material-icons-extended", "androidx.compose.material:material-icons-extended:$androidxComposeVersion")
library("androidx-compose-tooling", "androidx.compose.ui:ui-tooling:$androidxComposeVersion")
library("androidx-compose-ui", "androidx.compose.ui:ui:$androidxComposeVersion")
library("androidx-compose-ui-fonts", "androidx.compose.ui:ui-text-google-fonts:$androidxComposeVersion")
library("androidx-compose-compiler", "androidx.compose.compiler:compiler:$androidxComposeCompilerVersion")
library("androidx-security-crypto", "androidx.security:security-crypto-ktx:$androidxSecurityCryptoVersion")
// Test libraries
library("androidx-espresso-contrib", "androidx.test.espresso:espresso-contrib:$androidxEspressoVersion")
library("androidx-espresso-core", "androidx.test.espresso:espresso-core:$androidxEspressoVersion")
@ -173,16 +196,6 @@ dependencyResolutionManagement {
library("mockito-kotlin", "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockitoKotlinVersion")
// Bundles
bundle(
"androidx-test",
listOf(
"androidx-espresso-core",
"androidx-espresso-intents",
"androidx-test-junit",
"androidx-test-core"
)
)
bundle(
"grpc",
listOf(
@ -193,6 +206,39 @@ dependencyResolutionManagement {
)
)
bundle(
"androidx-compose-core",
listOf(
"androidx-compose-compiler",
"androidx-compose-foundation",
"androidx-compose-material3",
"androidx-compose-tooling",
"androidx-compose-ui",
"androidx-compose-ui-fonts"
)
)
bundle(
"androidx-compose-extended",
listOf(
"androidx-activity-compose",
"androidx-compose-material-icons-core",
"androidx-compose-material-icons-extended",
"androidx-lifecycle-compose",
"androidx-navigation-compose",
"androidx-viewmodel-compose"
)
)
bundle(
"androidx-test",
listOf(
"androidx-espresso-core",
"androidx-espresso-intents",
"androidx-test-junit",
"androidx-test-core"
)
)
bundle(
"junit",
listOf(

Some files were not shown because too many files have changed in this diff Show More