[#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
|
multiDexEnabled = true
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
viewBinding = true
|
viewBinding = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = libs.androidx.compose.compiler.get().versionConstraint.displayName
|
||||||
|
}
|
||||||
|
|
||||||
val releaseKeystorePath = project.property("ZCASH_RELEASE_KEYSTORE_PATH").toString()
|
val releaseKeystorePath = project.property("ZCASH_RELEASE_KEYSTORE_PATH").toString()
|
||||||
val releaseKeystorePassword = project.property("ZCASH_RELEASE_KEYSTORE_PASSWORD").toString()
|
val releaseKeystorePassword = project.property("ZCASH_RELEASE_KEYSTORE_PASSWORD").toString()
|
||||||
val releaseKeyAlias = project.property("ZCASH_RELEASE_KEY_ALIAS").toString()
|
val releaseKeyAlias = project.property("ZCASH_RELEASE_KEY_ALIAS").toString()
|
||||||
|
@ -104,19 +110,27 @@ dependencies {
|
||||||
implementation(libs.bip39)
|
implementation(libs.bip39)
|
||||||
|
|
||||||
// Android
|
// Android
|
||||||
implementation(libs.androidx.core)
|
|
||||||
implementation(libs.androidx.constraintlayout)
|
implementation(libs.androidx.constraintlayout)
|
||||||
|
implementation(libs.androidx.core)
|
||||||
implementation(libs.androidx.multidex)
|
implementation(libs.androidx.multidex)
|
||||||
implementation(libs.androidx.navigation.fragment)
|
implementation(libs.androidx.navigation.fragment)
|
||||||
implementation(libs.androidx.navigation.ui)
|
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
|
// Just to support profile installation and tracing events needed by benchmark tests
|
||||||
implementation(libs.androidx.profileinstaller)
|
implementation(libs.androidx.profileinstaller)
|
||||||
implementation(libs.androidx.tracing)
|
implementation(libs.androidx.tracing)
|
||||||
|
|
||||||
implementation(libs.material)
|
|
||||||
androidTestImplementation(libs.bundles.androidx.test)
|
androidTestImplementation(libs.bundles.androidx.test)
|
||||||
|
androidTestImplementation(libs.kotlin.reflect)
|
||||||
|
androidTestImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
androidTestImplementation(libs.kotlin.test)
|
||||||
|
|
||||||
implementation(libs.bundles.grpc)
|
implementation(libs.bundles.grpc)
|
||||||
|
implementation(libs.kotlinx.datetime)
|
||||||
}
|
}
|
||||||
|
|
||||||
fladle {
|
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 androidx.test.platform.app.InstrumentationRegistry
|
||||||
import cash.z.ecc.android.sdk.Synchronizer
|
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.convertZecToZatoshi
|
||||||
import cash.z.ecc.android.sdk.ext.toHex
|
import cash.z.ecc.android.sdk.ext.toHex
|
||||||
import cash.z.ecc.android.sdk.internal.TroubleshootingTwig
|
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.defaultForNetwork
|
||||||
import cash.z.ecc.android.sdk.model.isFailure
|
import cash.z.ecc.android.sdk.model.isFailure
|
||||||
import cash.z.ecc.android.sdk.tool.DerivationTool
|
import cash.z.ecc.android.sdk.tool.DerivationTool
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
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">
|
android:theme="@style/AppTheme">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
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>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
@ -21,6 +22,12 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".ComposeActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"/>
|
||||||
|
|
||||||
<!-- Enable profiling by benchmark -->
|
<!-- Enable profiling by benchmark -->
|
||||||
<profileable
|
<profileable
|
||||||
android:shell="true"
|
android:shell="true"
|
||||||
|
@ -30,11 +37,10 @@
|
||||||
see https://issuetracker.google.com/issues/258619948 -->
|
see https://issuetracker.google.com/issues/258619948 -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name="androidx.profileinstaller.ProfileInstallReceiver"
|
android:name="androidx.profileinstaller.ProfileInstallReceiver"
|
||||||
android:permission="android.permission.DUMP"
|
android:exported="true"
|
||||||
android:exported="true">
|
android:permission="android.permission.DUMP">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action
|
<action android:name="androidx.profileinstaller.action.BENCHMARK_OPERATION" />
|
||||||
android:name="androidx.profileinstaller.action.BENCHMARK_OPERATION" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
|
|
@ -1,18 +1,24 @@
|
||||||
package cash.z.ecc.android.sdk.demoapp
|
package cash.z.ecc.android.sdk.demoapp
|
||||||
|
|
||||||
import androidx.multidex.MultiDexApplication
|
import androidx.multidex.MultiDexApplication
|
||||||
import cash.z.ecc.android.sdk.internal.TroubleshootingTwig
|
import cash.z.ecc.android.sdk.demoapp.util.Twig
|
||||||
import cash.z.ecc.android.sdk.internal.Twig
|
|
||||||
|
|
||||||
class App : MultiDexApplication() {
|
class App : MultiDexApplication() {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
|
Twig.initialize(applicationContext)
|
||||||
|
Twig.info { "Starting application…" }
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
StrictModeHelper.enableStrictMode()
|
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.setupActionBarWithNavController
|
||||||
import androidx.navigation.ui.setupWithNavController
|
import androidx.navigation.ui.setupWithNavController
|
||||||
import androidx.viewbinding.ViewBinding
|
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.LightWalletGrpcService
|
||||||
import cash.z.ecc.android.sdk.internal.service.LightWalletService
|
import cash.z.ecc.android.sdk.internal.service.LightWalletService
|
||||||
import cash.z.ecc.android.sdk.internal.twig
|
import cash.z.ecc.android.sdk.internal.twig
|
||||||
|
@ -109,6 +109,9 @@ class MainActivity :
|
||||||
navController.navigate(R.id.nav_home)
|
navController.navigate(R.id.nav_home)
|
||||||
sharedViewModel.resetSDK()
|
sharedViewModel.resetSDK()
|
||||||
true
|
true
|
||||||
|
} else if (item.itemId == R.id.action_new_ui) {
|
||||||
|
startActivity(Intent(this, ComposeActivity::class.java))
|
||||||
|
true
|
||||||
} else {
|
} else {
|
||||||
super.onOptionsItemSelected(item)
|
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.Mnemonics
|
||||||
import cash.z.ecc.android.bip39.toSeed
|
import cash.z.ecc.android.bip39.toSeed
|
||||||
import cash.z.ecc.android.sdk.Synchronizer
|
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.BenchmarkingExt
|
||||||
import cash.z.ecc.android.sdk.ext.onFirst
|
import cash.z.ecc.android.sdk.ext.onFirst
|
||||||
import cash.z.ecc.android.sdk.fixture.BlockRangeFixture
|
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.BaseDemoFragment
|
||||||
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetAddressBinding
|
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.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.ProvideAddressBenchmarkTrace
|
||||||
import cash.z.ecc.android.sdk.demoapp.util.fromResources
|
|
||||||
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||||
import cash.z.ecc.android.sdk.tool.DerivationTool
|
import cash.z.ecc.android.sdk.tool.DerivationTool
|
||||||
import cash.z.ecc.android.sdk.type.UnifiedFullViewingKey
|
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.R
|
||||||
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetBalanceBinding
|
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.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.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.ZcashSdk
|
||||||
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
|
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
|
||||||
import cash.z.ecc.android.sdk.internal.twig
|
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.BaseDemoFragment
|
||||||
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetBlockBinding
|
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.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.mainActivity
|
||||||
import cash.z.ecc.android.sdk.demoapp.util.toHtml
|
import cash.z.ecc.android.sdk.demoapp.util.toHtml
|
||||||
import cash.z.ecc.android.sdk.demoapp.util.toRelativeTime
|
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.R
|
||||||
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetBlockRangeBinding
|
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.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.mainActivity
|
||||||
import cash.z.ecc.android.sdk.demoapp.util.toRelativeTime
|
import cash.z.ecc.android.sdk.demoapp.util.toRelativeTime
|
||||||
import cash.z.ecc.android.sdk.demoapp.util.withCommas
|
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.BaseDemoFragment
|
||||||
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetPrivateKeyBinding
|
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.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.Account
|
||||||
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||||
import cash.z.ecc.android.sdk.tool.DerivationTool
|
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.BaseDemoFragment
|
||||||
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentListUtxosBinding
|
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.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.mainActivity
|
||||||
import cash.z.ecc.android.sdk.internal.twig
|
import cash.z.ecc.android.sdk.internal.twig
|
||||||
import cash.z.ecc.android.sdk.model.Account
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<menu
|
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
<!-- Note: We hide entire group on SDK sync related screens -->
|
<!-- Note: We hide entire group on SDK sync related screens -->
|
||||||
<group
|
<group android:id="@+id/main_menu_group">
|
||||||
android:id="@+id/main_menu_group">
|
|
||||||
<item
|
<item
|
||||||
android:id="@+id/action_settings"
|
android:id="@+id/action_settings"
|
||||||
android:orderInCategory="100"
|
android:orderInCategory="100"
|
||||||
|
@ -21,4 +19,9 @@
|
||||||
android:title="@string/action_reset_sdk"
|
android:title="@string/action_reset_sdk"
|
||||||
app:showAsAction="never" />
|
app:showAsAction="never" />
|
||||||
</group>
|
</group>
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_new_ui"
|
||||||
|
android:orderInCategory="100"
|
||||||
|
android:title="@string/action_new_ui"
|
||||||
|
app:showAsAction="never" />
|
||||||
</menu>
|
</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_faucet">Testnet Faucet</string>
|
||||||
<string name="action_reset_sdk">Reset SDK</string>
|
<string name="action_reset_sdk">Reset SDK</string>
|
||||||
|
|
||||||
|
<string name="action_new_ui">New UI</string>
|
||||||
|
|
||||||
<!-- Drawer Menu -->
|
<!-- Drawer Menu -->
|
||||||
<string name="menu_home">Home</string>
|
<string name="menu_home">Home</string>
|
||||||
<string name="menu_private_key">Get Private Key</string>
|
<string name="menu_private_key">Get Private Key</string>
|
||||||
|
@ -31,4 +33,29 @@
|
||||||
<string name="sapling_address">Sapling address</string>
|
<string name="sapling_address">Sapling address</string>
|
||||||
<string name="transparent_address">Transparent address</string>
|
<string name="transparent_address">Transparent address</string>
|
||||||
<string name="action_shield">Shield Funds</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>
|
</resources>
|
||||||
|
|
|
@ -86,19 +86,25 @@ KSP_VERSION=1.7.21-1.0.8
|
||||||
PROTOBUF_GRADLE_PLUGIN_VERSION=0.8.19
|
PROTOBUF_GRADLE_PLUGIN_VERSION=0.8.19
|
||||||
RUST_GRADLE_PLUGIN_VERSION=0.9.3
|
RUST_GRADLE_PLUGIN_VERSION=0.9.3
|
||||||
|
|
||||||
|
ANDROIDX_ACTIVITY_VERSION=1.6.1
|
||||||
ANDROIDX_ANNOTATION_VERSION=1.5.0
|
ANDROIDX_ANNOTATION_VERSION=1.5.0
|
||||||
ANDROIDX_APPCOMPAT_VERSION=1.5.1
|
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_CONSTRAINT_LAYOUT_VERSION=2.1.4
|
||||||
ANDROIDX_CORE_VERSION=1.9.0
|
ANDROIDX_CORE_VERSION=1.9.0
|
||||||
ANDROIDX_DATABASE_VERSION=2.2.0
|
ANDROIDX_DATABASE_VERSION=2.2.0
|
||||||
ANDROIDX_ESPRESSO_VERSION=3.5.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_MULTIDEX_VERSION=2.0.1
|
||||||
ANDROIDX_NAVIGATION_VERSION=2.5.3
|
ANDROIDX_NAVIGATION_VERSION=2.5.3
|
||||||
|
ANDROIDX_NAVIGATION_COMPOSE_VERSION=2.5.3
|
||||||
ANDROIDX_NAVIGATION_FRAGMENT_VERSION=2.4.2
|
ANDROIDX_NAVIGATION_FRAGMENT_VERSION=2.4.2
|
||||||
ANDROIDX_PAGING_VERSION=2.1.2
|
ANDROIDX_PAGING_VERSION=2.1.2
|
||||||
ANDROIDX_PROFILE_INSTALLER_VERSION=1.3.0-alpha02
|
ANDROIDX_PROFILE_INSTALLER_VERSION=1.3.0-alpha02
|
||||||
ANDROIDX_ROOM_VERSION=2.4.3
|
ANDROIDX_ROOM_VERSION=2.4.3
|
||||||
|
ANDROIDX_SECURITY_CRYPTO_VERSION=1.1.0-alpha04
|
||||||
ANDROIDX_TEST_JUNIT_VERSION=1.1.4
|
ANDROIDX_TEST_JUNIT_VERSION=1.1.4
|
||||||
ANDROIDX_TEST_MACROBENCHMARK_VERSION=1.2.0-alpha08
|
ANDROIDX_TEST_MACROBENCHMARK_VERSION=1.2.0-alpha08
|
||||||
ANDROIDX_TEST_ORCHESTRATOR_VERSION=1.4.2
|
ANDROIDX_TEST_ORCHESTRATOR_VERSION=1.4.2
|
||||||
|
@ -115,6 +121,7 @@ JACOCO_VERSION=0.8.8
|
||||||
JAVAX_ANNOTATION_VERSION=1.3.2
|
JAVAX_ANNOTATION_VERSION=1.3.2
|
||||||
JUNIT_VERSION=5.9.1
|
JUNIT_VERSION=5.9.1
|
||||||
KOTLINX_COROUTINES_VERSION=1.6.4
|
KOTLINX_COROUTINES_VERSION=1.6.4
|
||||||
|
KOTLINX_DATETIME_VERSION=0.4.0
|
||||||
KOTLIN_VERSION=1.7.21
|
KOTLIN_VERSION=1.7.21
|
||||||
MOCKITO_KOTLIN_VERSION=2.2.0
|
MOCKITO_KOTLIN_VERSION=2.2.0
|
||||||
MOCKITO_VERSION=4.9.0
|
MOCKITO_VERSION=4.9.0
|
||||||
|
|
|
@ -65,8 +65,12 @@ dependencyResolutionManagement {
|
||||||
versionCatalogs {
|
versionCatalogs {
|
||||||
create("libs") {
|
create("libs") {
|
||||||
val androidGradlePluginVersion = extra["ANDROID_GRADLE_PLUGIN_VERSION"].toString()
|
val androidGradlePluginVersion = extra["ANDROID_GRADLE_PLUGIN_VERSION"].toString()
|
||||||
|
val androidxActivityVersion = extra["ANDROIDX_ACTIVITY_VERSION"].toString()
|
||||||
val androidxAnnotationVersion = extra["ANDROIDX_ANNOTATION_VERSION"].toString()
|
val androidxAnnotationVersion = extra["ANDROIDX_ANNOTATION_VERSION"].toString()
|
||||||
val androidxAppcompatVersion = extra["ANDROIDX_APPCOMPAT_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 androidxConstraintLayoutVersion = extra["ANDROIDX_CONSTRAINT_LAYOUT_VERSION"].toString()
|
||||||
val androidxCoreVersion = extra["ANDROIDX_CORE_VERSION"].toString()
|
val androidxCoreVersion = extra["ANDROIDX_CORE_VERSION"].toString()
|
||||||
val androidxDatabaseVersion = extra["ANDROIDX_DATABASE_VERSION"].toString()
|
val androidxDatabaseVersion = extra["ANDROIDX_DATABASE_VERSION"].toString()
|
||||||
|
@ -74,10 +78,12 @@ dependencyResolutionManagement {
|
||||||
val androidxLifecycleVersion = extra["ANDROIDX_LIFECYCLE_VERSION"].toString()
|
val androidxLifecycleVersion = extra["ANDROIDX_LIFECYCLE_VERSION"].toString()
|
||||||
val androidxMultidexVersion = extra["ANDROIDX_MULTIDEX_VERSION"].toString()
|
val androidxMultidexVersion = extra["ANDROIDX_MULTIDEX_VERSION"].toString()
|
||||||
val androidxNavigationVersion = extra["ANDROIDX_NAVIGATION_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 androidxNavigationFragmentVersion = extra["ANDROIDX_NAVIGATION_FRAGMENT_VERSION"].toString()
|
||||||
val androidxPagingVersion = extra["ANDROIDX_PAGING_VERSION"].toString()
|
val androidxPagingVersion = extra["ANDROIDX_PAGING_VERSION"].toString()
|
||||||
val androidxProfileInstallerVersion = extra["ANDROIDX_PROFILE_INSTALLER_VERSION"].toString()
|
val androidxProfileInstallerVersion = extra["ANDROIDX_PROFILE_INSTALLER_VERSION"].toString()
|
||||||
val androidxRoomVersion = extra["ANDROIDX_ROOM_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 androidxTestJunitVersion = extra["ANDROIDX_TEST_JUNIT_VERSION"].toString()
|
||||||
val androidxTestMacrobenchmarkVersion = extra["ANDROIDX_TEST_MACROBENCHMARK_VERSION"].toString()
|
val androidxTestMacrobenchmarkVersion = extra["ANDROIDX_TEST_MACROBENCHMARK_VERSION"].toString()
|
||||||
val androidxTestOrchestratorVersion = extra["ANDROIDX_TEST_ORCHESTRATOR_VERSION"].toString()
|
val androidxTestOrchestratorVersion = extra["ANDROIDX_TEST_ORCHESTRATOR_VERSION"].toString()
|
||||||
|
@ -96,6 +102,7 @@ dependencyResolutionManagement {
|
||||||
val junitVersion = extra["JUNIT_VERSION"].toString()
|
val junitVersion = extra["JUNIT_VERSION"].toString()
|
||||||
val kotlinVersion = extra["KOTLIN_VERSION"].toString()
|
val kotlinVersion = extra["KOTLIN_VERSION"].toString()
|
||||||
val kotlinxCoroutinesVersion = extra["KOTLINX_COROUTINES_VERSION"].toString()
|
val kotlinxCoroutinesVersion = extra["KOTLINX_COROUTINES_VERSION"].toString()
|
||||||
|
val kotlinxDateTimeVersion = extra["KOTLINX_DATETIME_VERSION"].toString()
|
||||||
val mockitoKotlinVersion = extra["MOCKITO_KOTLIN_VERSION"].toString()
|
val mockitoKotlinVersion = extra["MOCKITO_KOTLIN_VERSION"].toString()
|
||||||
val mockitoVersion = extra["MOCKITO_VERSION"].toString()
|
val mockitoVersion = extra["MOCKITO_VERSION"].toString()
|
||||||
val protocVersion = extra["PROTOC_VERSION"].toString()
|
val protocVersion = extra["PROTOC_VERSION"].toString()
|
||||||
|
@ -121,12 +128,15 @@ dependencyResolutionManagement {
|
||||||
|
|
||||||
// Libraries
|
// Libraries
|
||||||
library("androidx-annotation", "androidx.annotation:annotation:$androidxAnnotationVersion")
|
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-appcompat", "androidx.appcompat:appcompat:$androidxAppcompatVersion")
|
||||||
library("androidx-constraintlayout", "androidx.constraintlayout:constraintlayout:$androidxConstraintLayoutVersion")
|
library("androidx-constraintlayout", "androidx.constraintlayout:constraintlayout:$androidxConstraintLayoutVersion")
|
||||||
library("androidx-core", "androidx.core:core-ktx:$androidxCoreVersion")
|
library("androidx-core", "androidx.core:core-ktx:$androidxCoreVersion")
|
||||||
library("androidx-lifecycle-common", "androidx.lifecycle:lifecycle-common-java8:$androidxLifecycleVersion")
|
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-lifecycle-runtime", "androidx.lifecycle:lifecycle-runtime-ktx:$androidxLifecycleVersion")
|
||||||
library("androidx-multidex", "androidx.multidex:multidex:$androidxMultidexVersion")
|
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-fragment", "androidx.navigation:navigation-fragment-ktx:$androidxNavigationFragmentVersion")
|
||||||
library("androidx-navigation-ui", "androidx.navigation:navigation-ui-ktx:$androidxNavigationVersion")
|
library("androidx-navigation-ui", "androidx.navigation:navigation-ui-ktx:$androidxNavigationVersion")
|
||||||
library("androidx-paging", "androidx.paging:paging-runtime-ktx:$androidxPagingVersion")
|
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-room-core", "androidx.room:room-ktx:$androidxRoomVersion")
|
||||||
library("androidx-sqlite", "androidx.sqlite:sqlite-ktx:${androidxDatabaseVersion}")
|
library("androidx-sqlite", "androidx.sqlite:sqlite-ktx:${androidxDatabaseVersion}")
|
||||||
library("androidx-sqlite-framework", "androidx.sqlite:sqlite-framework:${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("bip39", "cash.z.ecc.android:kotlin-bip39:$bip39Version")
|
||||||
library("grpc-android", "io.grpc:grpc-android:$grpcVersion")
|
library("grpc-android", "io.grpc:grpc-android:$grpcVersion")
|
||||||
library("grpc-okhttp", "io.grpc:grpc-okhttp:$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("kotlin-stdlib", "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
|
||||||
library("kotlinx-coroutines-android", "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion")
|
library("kotlinx-coroutines-android", "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion")
|
||||||
library("kotlinx-coroutines-core", "org.jetbrains.kotlinx:kotlinx-coroutines-core:$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("material", "com.google.android.material:material:$googleMaterialVersion")
|
||||||
library("zcashwalletplgn", "com.github.zcash:zcash-android-wallet-plugins:$zcashWalletPluginVersion")
|
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
|
// Test libraries
|
||||||
library("androidx-espresso-contrib", "androidx.test.espresso:espresso-contrib:$androidxEspressoVersion")
|
library("androidx-espresso-contrib", "androidx.test.espresso:espresso-contrib:$androidxEspressoVersion")
|
||||||
library("androidx-espresso-core", "androidx.test.espresso:espresso-core:$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")
|
library("mockito-kotlin", "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockitoKotlinVersion")
|
||||||
|
|
||||||
// Bundles
|
// Bundles
|
||||||
bundle(
|
|
||||||
"androidx-test",
|
|
||||||
listOf(
|
|
||||||
"androidx-espresso-core",
|
|
||||||
"androidx-espresso-intents",
|
|
||||||
"androidx-test-junit",
|
|
||||||
"androidx-test-core"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
bundle(
|
bundle(
|
||||||
"grpc",
|
"grpc",
|
||||||
listOf(
|
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(
|
bundle(
|
||||||
"junit",
|
"junit",
|
||||||
listOf(
|
listOf(
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue