[#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:
parent
ee388448be
commit
a4a6b25bfb
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
|
@ -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}"))
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
|
@ -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)
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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.
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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() })
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"))
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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())
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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?
|
||||
)
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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!!
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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">Alyssa’s unified address</string>
|
||||
<string name="send_alyssa_sapling">Alyssa’s sapling address</string>
|
||||
<string name="send_alyssa_transparent">Alyssa’s transparent address</string>
|
||||
|
||||
<string name="send_ben_unified">Ben’s unified address</string>
|
||||
<string name="send_ben_sapling">Ben’s sapling address</string>
|
||||
<string name="send_ben_transparent">Ben’s 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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue