From a4a6b25bfbda7786cdb47f17d3e49ba4eed8b9f8 Mon Sep 17 00:00:00 2001 From: Carter Jernigan Date: Fri, 23 Dec 2022 05:00:37 -0500 Subject: [PATCH] [#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 --- demo-app/build.gradle.kts | 18 +- .../z/wallet/sdk/sample/demoapp/Iterator.kt | 10 + .../sdk/sample/demoapp/SampleCodeTest.kt | 3 +- .../sdk/sample/demoapp/ext/StringExtTest.kt | 22 ++ .../fixture/CurrencyConversionFixture.kt | 18 + .../demoapp/fixture/FiatCurrencyFixture.kt | 9 + .../sample/demoapp/fixture/LocaleFixture.kt | 15 + .../sdk/sample/demoapp/fixture/MemoFixture.kt | 9 + .../fixture/MonetarySeparatorsFixture.kt | 13 + .../fixture/PersistableWalletFixture.kt | 23 ++ .../demoapp/fixture/SeedPhraseFixture.kt | 10 + .../demoapp/fixture/WalletAddressFixture.kt | 19 ++ .../demoapp/fixture/WalletAddressesFixture.kt | 17 + .../sample/demoapp/fixture/ZatoshiFixture.kt | 10 + .../sample/demoapp/fixture/ZecSendFixture.kt | 21 ++ .../FiatCurrencyConversionRateStateTest.kt | 110 +++++++ .../sdk/sample/demoapp/model/LocaleTest.kt | 29 ++ .../sdk/sample/demoapp/model/MemoTest.kt | 30 ++ .../demoapp/model/PercentDecimalTest.kt | 20 ++ .../demoapp/model/PersistableWalletTest.kt | 52 +++ .../sample/demoapp/model/SeedPhraseTest.kt | 27 ++ .../sample/demoapp/model/WalletAddressTest.kt | 18 + .../demoapp/model/WalletAddressesTest.kt | 21 ++ .../sample/demoapp/model/ZatoshiExtTest.kt | 78 +++++ .../sample/demoapp/model/ZecStringExtTest.kt | 169 ++++++++++ .../sdk/sample/demoapp/model/ZecStringTest.kt | 102 ++++++ .../preference/EncryptedPreferenceKeysTest.kt | 28 ++ .../preference/MockPreferenceProvider.kt | 26 ++ .../BooleanPreferenceDefaultFixture.kt | 10 + .../IntegerPreferenceDefaultFixture.kt | 10 + .../fixture/StringDefaultPreferenceFixture.kt | 10 + .../entry/BooleanPreferenceDefaultTest.kt | 43 +++ .../entry/IntegerPreferenceDefaultTest.kt | 32 ++ .../entry/StringPreferenceDefaultTest.kt | 30 ++ .../sample/demoapp/ui/common/FlowExtTest.kt | 68 ++++ .../sdk/sample/demoapp/ui/common/Global.kt | 14 + demo-app/src/main/AndroidManifest.xml | 18 +- .../cash/z/ecc/android/sdk/demoapp/App.kt | 14 +- .../android/sdk/demoapp/ComposeActivity.kt | 52 +++ .../z/ecc/android/sdk/demoapp/MainActivity.kt | 5 +- .../z/ecc/android/sdk/demoapp/Navigation.kt | 128 ++++++++ .../android/sdk/demoapp/SharedViewModel.kt | 2 +- .../android/sdk/demoapp/WalletCoordinator.kt | 163 ++++++++++ .../sdk/demoapp/WalletCoordinatorFactory.kt | 25 ++ .../demos/getaddress/GetAddressFragment.kt | 2 +- .../demos/getbalance/GetBalanceFragment.kt | 2 +- .../demos/getblock/GetBlockFragment.kt | 2 +- .../getblockrange/GetBlockRangeFragment.kt | 2 +- .../getprivatekey/GetPrivateKeyFragment.kt | 2 +- .../demos/listutxos/ListUtxosFragment.kt | 2 +- .../ecc/android/sdk/demoapp/ext/StringExt.kt | 9 + .../android/sdk/demoapp/ext/ui/ZatoshiExt.kt | 90 +++++ .../demoapp/fixture/WalletAddressFixture.kt | 17 + .../demoapp/fixture/WalletAddressesFixture.kt | 17 + .../sdk/demoapp/fixture/WalletFixture.kt | 79 +++++ .../demoapp/fixture/WalletSnapshotFixture.kt | 47 +++ .../sdk/demoapp/model/CurrencyConversion.kt | 35 ++ .../model/FiatCurrencyConversionRateState.kt | 40 +++ .../z/ecc/android/sdk/demoapp/model/Locale.kt | 31 ++ .../z/ecc/android/sdk/demoapp/model/Memo.kt | 24 ++ .../sdk/demoapp/model/PercentDecimal.kt | 25 ++ .../sdk/demoapp/model/PersistableWallet.kt | 89 +++++ .../android/sdk/demoapp/model/SeedPhrase.kt | 25 ++ .../sdk/demoapp/model/WalletAddress.kt | 46 +++ .../sdk/demoapp/model/WalletAddresses.kt | 35 ++ .../ecc/android/sdk/demoapp/model/ZecSend.kt | 16 + .../android/sdk/demoapp/model/ZecSendExt.kt | 47 +++ .../android/sdk/demoapp/model/ZecString.kt | 106 ++++++ .../android/sdk/demoapp/model/ZecStringExt.kt | 91 ++++++ .../preference/AndroidPreferenceProvider.kt | 116 +++++++ .../preference/EncryptedPreferenceKeys.kt | 8 + .../EncryptedPreferenceSingleton.kt | 16 + .../PersistableWalletPreferenceDefault.kt | 20 ++ .../preference/api/PreferenceProvider.kt | 19 ++ .../model/entry/BooleanPreferenceDefault.kt | 23 ++ .../model/entry/IntegerPreferenceDefault.kt | 22 ++ .../sdk/demoapp/preference/model/entry/Key.kt | 34 ++ .../model/entry/PreferenceDefault.kt | 47 +++ .../model/entry/StringPreferenceDefault.kt | 16 + .../android/sdk/demoapp/type/ZcashNetwork.kt | 31 ++ .../sdk/demoapp/ui/common/Constants.kt | 11 + .../android/sdk/demoapp/ui/common/FlowExt.kt | 58 ++++ .../ui/screen/addresses/view/AddressesView.kt | 135 ++++++++ .../demoapp/ui/screen/home/view/HomeView.kt | 115 +++++++ .../screen/home/viewmodel/WalletSnapshot.kt | 33 ++ .../screen/home/viewmodel/WalletViewModel.kt | 307 ++++++++++++++++++ .../ui/screen/seed/view/ConfigureSeedView.kt | 97 ++++++ .../demoapp/ui/screen/send/view/SendView.kt | 240 ++++++++++++++ .../sdk/demoapp/util/AndroidApiVersion.kt | 40 +++ .../sdk/demoapp/util/LazyWithArgument.kt | 33 ++ .../android/sdk/demoapp/util/SampleStorage.kt | 63 ---- .../sdk/demoapp/util/SimpleMnemonics.kt | 27 -- .../sdk/demoapp/util/SuspendingLazy.kt | 28 ++ .../z/ecc/android/sdk/demoapp/util/Twig.kt | 180 ++++++++++ demo-app/src/main/res/menu/main.xml | 11 +- demo-app/src/main/res/values/bools.xml | 6 + .../src/main/res/values/strings-regex.xml | 4 + demo-app/src/main/res/values/strings.xml | 27 ++ gradle.properties | 9 +- settings.gradle.kts | 66 +++- tools/detekt.yml | 2 + 101 files changed, 4149 insertions(+), 127 deletions(-) create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/Iterator.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/ext/StringExtTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/CurrencyConversionFixture.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/FiatCurrencyFixture.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/LocaleFixture.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/MemoFixture.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/MonetarySeparatorsFixture.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/PersistableWalletFixture.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/SeedPhraseFixture.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/WalletAddressFixture.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/WalletAddressesFixture.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/ZatoshiFixture.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/ZecSendFixture.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/FiatCurrencyConversionRateStateTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/LocaleTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/MemoTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/PercentDecimalTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/PersistableWalletTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/SeedPhraseTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/WalletAddressTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/WalletAddressesTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/ZatoshiExtTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/ZecStringExtTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/ZecStringTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/EncryptedPreferenceKeysTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/MockPreferenceProvider.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/fixture/BooleanPreferenceDefaultFixture.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/fixture/IntegerPreferenceDefaultFixture.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/fixture/StringDefaultPreferenceFixture.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/model/entry/BooleanPreferenceDefaultTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/model/entry/IntegerPreferenceDefaultTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/model/entry/StringPreferenceDefaultTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/ui/common/FlowExtTest.kt create mode 100644 demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/ui/common/Global.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ComposeActivity.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/Navigation.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/WalletCoordinator.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/WalletCoordinatorFactory.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ext/StringExt.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ext/ui/ZatoshiExt.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/fixture/WalletAddressFixture.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/fixture/WalletAddressesFixture.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/fixture/WalletFixture.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/fixture/WalletSnapshotFixture.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/CurrencyConversion.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/FiatCurrencyConversionRateState.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/Locale.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/Memo.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/PercentDecimal.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/PersistableWallet.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/SeedPhrase.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/WalletAddress.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/WalletAddresses.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/ZecSend.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/ZecSendExt.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/ZecString.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/ZecStringExt.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/AndroidPreferenceProvider.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/EncryptedPreferenceKeys.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/EncryptedPreferenceSingleton.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/PersistableWalletPreferenceDefault.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/api/PreferenceProvider.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/BooleanPreferenceDefault.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/IntegerPreferenceDefault.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/Key.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/PreferenceDefault.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/StringPreferenceDefault.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/type/ZcashNetwork.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/common/Constants.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/common/FlowExt.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/addresses/view/AddressesView.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/home/view/HomeView.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/home/viewmodel/WalletSnapshot.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/home/viewmodel/WalletViewModel.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/seed/view/ConfigureSeedView.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/send/view/SendView.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/AndroidApiVersion.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/LazyWithArgument.kt delete mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/SampleStorage.kt delete mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/SimpleMnemonics.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/SuspendingLazy.kt create mode 100644 demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/Twig.kt create mode 100644 demo-app/src/main/res/values/bools.xml create mode 100644 demo-app/src/main/res/values/strings-regex.xml diff --git a/demo-app/build.gradle.kts b/demo-app/build.gradle.kts index 8fd3e728..ac5320e5 100644 --- a/demo-app/build.gradle.kts +++ b/demo-app/build.gradle.kts @@ -18,10 +18,16 @@ android { multiDexEnabled = true vectorDrawables.useSupportLibrary = true } + buildFeatures { + compose = true viewBinding = true } + composeOptions { + kotlinCompilerExtensionVersion = libs.androidx.compose.compiler.get().versionConstraint.displayName + } + val releaseKeystorePath = project.property("ZCASH_RELEASE_KEYSTORE_PATH").toString() val releaseKeystorePassword = project.property("ZCASH_RELEASE_KEYSTORE_PASSWORD").toString() val releaseKeyAlias = project.property("ZCASH_RELEASE_KEY_ALIAS").toString() @@ -104,19 +110,27 @@ dependencies { implementation(libs.bip39) // Android - implementation(libs.androidx.core) implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.core) implementation(libs.androidx.multidex) implementation(libs.androidx.navigation.fragment) implementation(libs.androidx.navigation.ui) + implementation(libs.androidx.security.crypto) + implementation(libs.bundles.androidx.compose.core) + implementation(libs.bundles.androidx.compose.extended) + implementation(libs.material) + // Just to support profile installation and tracing events needed by benchmark tests implementation(libs.androidx.profileinstaller) implementation(libs.androidx.tracing) - implementation(libs.material) androidTestImplementation(libs.bundles.androidx.test) + androidTestImplementation(libs.kotlin.reflect) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.kotlin.test) implementation(libs.bundles.grpc) + implementation(libs.kotlinx.datetime) } fladle { diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/Iterator.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/Iterator.kt new file mode 100644 index 00000000..a6dc5bdd --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/Iterator.kt @@ -0,0 +1,10 @@ +@file:Suppress("ktlint:filename") + +package cash.z.wallet.sdk.sample.demoapp + +fun Iterator.count(): Int { + var count = 0 + forEach { count++ } + + return count +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/SampleCodeTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/SampleCodeTest.kt index c65e20b9..e559759c 100644 --- a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/SampleCodeTest.kt +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/SampleCodeTest.kt @@ -2,7 +2,7 @@ package cash.z.wallet.sdk.sample.demoapp import androidx.test.platform.app.InstrumentationRegistry import cash.z.ecc.android.sdk.Synchronizer -import cash.z.ecc.android.sdk.demoapp.util.fromResources +import cash.z.ecc.android.sdk.demoapp.type.fromResources import cash.z.ecc.android.sdk.ext.convertZecToZatoshi import cash.z.ecc.android.sdk.ext.toHex import cash.z.ecc.android.sdk.internal.TroubleshootingTwig @@ -17,7 +17,6 @@ import cash.z.ecc.android.sdk.model.ZcashNetwork import cash.z.ecc.android.sdk.model.defaultForNetwork import cash.z.ecc.android.sdk.model.isFailure import cash.z.ecc.android.sdk.tool.DerivationTool -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/ext/StringExtTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/ext/StringExtTest.kt new file mode 100644 index 00000000..cb65e7ff --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/ext/StringExtTest.kt @@ -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()) + } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/CurrencyConversionFixture.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/CurrencyConversionFixture.kt new file mode 100644 index 00000000..4bbd724f --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/CurrencyConversionFixture.kt @@ -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) +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/FiatCurrencyFixture.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/FiatCurrencyFixture.kt new file mode 100644 index 00000000..8d03d354 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/FiatCurrencyFixture.kt @@ -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) +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/LocaleFixture.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/LocaleFixture.kt new file mode 100644 index 00000000..ed877ca0 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/LocaleFixture.kt @@ -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) +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/MemoFixture.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/MemoFixture.kt new file mode 100644 index 00000000..be870185 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/MemoFixture.kt @@ -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) +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/MonetarySeparatorsFixture.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/MonetarySeparatorsFixture.kt new file mode 100644 index 00000000..fcb4c550 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/MonetarySeparatorsFixture.kt @@ -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) +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/PersistableWalletFixture.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/PersistableWalletFixture.kt new file mode 100644 index 00000000..89693db4 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/PersistableWalletFixture.kt @@ -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) +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/SeedPhraseFixture.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/SeedPhraseFixture.kt new file mode 100644 index 00000000..db4f292d --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/SeedPhraseFixture.kt @@ -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) +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/WalletAddressFixture.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/WalletAddressFixture.kt new file mode 100644 index 00000000..08c9ed83 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/WalletAddressFixture.kt @@ -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) +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/WalletAddressesFixture.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/WalletAddressesFixture.kt new file mode 100644 index 00000000..2758e34e --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/WalletAddressesFixture.kt @@ -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) + ) +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/ZatoshiFixture.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/ZatoshiFixture.kt new file mode 100644 index 00000000..aac29861 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/ZatoshiFixture.kt @@ -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) +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/ZecSendFixture.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/ZecSendFixture.kt new file mode 100644 index 00000000..cc2580d7 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/fixture/ZecSendFixture.kt @@ -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) +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/FiatCurrencyConversionRateStateTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/FiatCurrencyConversionRateStateTest.kt new file mode 100644 index 00000000..193f675d --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/FiatCurrencyConversionRateStateTest.kt @@ -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(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(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(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(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(result) + } + + @Test + @SmallTest + fun null_conversion_rate() { + val zatoshi = ZatoshiFixture.new() + + val result = zatoshi.toFiatCurrencyState(null, LocaleFixture.new(), MonetarySeparatorsFixture.new()) + + assertIs(result) + } +} + +private class FrozenClock(private val timestamp: Instant) : Clock { + override fun now() = timestamp +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/LocaleTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/LocaleTest.kt new file mode 100644 index 00000000..44e1d5f1 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/LocaleTest.kt @@ -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()) + } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/MemoTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/MemoTest.kt new file mode 100644 index 00000000..ca166e78 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/MemoTest.kt @@ -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) + } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/PercentDecimalTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/PercentDecimalTest.kt new file mode 100644 index 00000000..5ec136fd --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/PercentDecimalTest.kt @@ -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) + } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/PersistableWalletTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/PersistableWalletTest.kt new file mode 100644 index 00000000..6a9f338b --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/PersistableWalletTest.kt @@ -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)) + } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/SeedPhraseTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/SeedPhraseTest.kt new file mode 100644 index 00000000..c459d83e --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/SeedPhraseTest.kt @@ -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)) + } + } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/WalletAddressTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/WalletAddressTest.kt new file mode 100644 index 00000000..2dcaf608 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/WalletAddressTest.kt @@ -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) + } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/WalletAddressesTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/WalletAddressesTest.kt new file mode 100644 index 00000000..a168ded2 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/WalletAddressesTest.kt @@ -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)) + } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/ZatoshiExtTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/ZatoshiExtTest.kt new file mode 100644 index 00000000..98545144 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/ZatoshiExtTest.kt @@ -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) } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/ZecStringExtTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/ZecStringExtTest.kt new file mode 100644 index 00000000..992a1025 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/ZecStringExtTest.kt @@ -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}")) + } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/ZecStringTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/ZecStringTest.kt new file mode 100644 index 00000000..c16f3c17 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/model/ZecStringTest.kt @@ -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() + 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)) + } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/EncryptedPreferenceKeysTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/EncryptedPreferenceKeysTest.kt new file mode 100644 index 00000000..f09453e2 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/EncryptedPreferenceKeysTest.kt @@ -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() + + 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) + } + } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/MockPreferenceProvider.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/MockPreferenceProvider.kt new file mode 100644 index 00000000..df347386 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/MockPreferenceProvider.kt @@ -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 = { 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 = flowOf(Unit) + + override suspend fun hasKey(key: Key) = map.containsKey(key.key) + + override suspend fun putString(key: Key, value: String?) { + map[key.key] = value + } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/fixture/BooleanPreferenceDefaultFixture.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/fixture/BooleanPreferenceDefaultFixture.kt new file mode 100644 index 00000000..7c023d32 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/fixture/BooleanPreferenceDefaultFixture.kt @@ -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) +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/fixture/IntegerPreferenceDefaultFixture.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/fixture/IntegerPreferenceDefaultFixture.kt new file mode 100644 index 00000000..1658c1b1 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/fixture/IntegerPreferenceDefaultFixture.kt @@ -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) +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/fixture/StringDefaultPreferenceFixture.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/fixture/StringDefaultPreferenceFixture.kt new file mode 100644 index 00000000..e7ad1242 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/fixture/StringDefaultPreferenceFixture.kt @@ -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) +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/model/entry/BooleanPreferenceDefaultTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/model/entry/BooleanPreferenceDefaultTest.kt new file mode 100644 index 00000000..d3dc6aad --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/model/entry/BooleanPreferenceDefaultTest.kt @@ -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)) + } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/model/entry/IntegerPreferenceDefaultTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/model/entry/IntegerPreferenceDefaultTest.kt new file mode 100644 index 00000000..17a212ea --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/model/entry/IntegerPreferenceDefaultTest.kt @@ -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)) + } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/model/entry/StringPreferenceDefaultTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/model/entry/StringPreferenceDefaultTest.kt new file mode 100644 index 00000000..85c1c0d6 --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/preference/model/entry/StringPreferenceDefaultTest.kt @@ -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)) + } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/ui/common/FlowExtTest.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/ui/common/FlowExtTest.kt new file mode 100644 index 00000000..5fc4dd2b --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/ui/common/FlowExtTest.kt @@ -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() + 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) } + } + } +} diff --git a/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/ui/common/Global.kt b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/ui/common/Global.kt new file mode 100644 index 00000000..ad276fed --- /dev/null +++ b/demo-app/src/androidTest/java/cash/z/wallet/sdk/sample/demoapp/ui/common/Global.kt @@ -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().getString(resId) + +fun getStringResourceWithArgs(@StringRes resId: Int, formatArgs: Array) = ApplicationProvider.getApplicationContext().getString(resId, *formatArgs) + +fun isLocaleRTL(locale: Locale) = TextUtils.getLayoutDirectionFromLocale(locale) == View.LAYOUT_DIRECTION_RTL diff --git a/demo-app/src/main/AndroidManifest.xml b/demo-app/src/main/AndroidManifest.xml index 19a35a97..7874f6fa 100644 --- a/demo-app/src/main/AndroidManifest.xml +++ b/demo-app/src/main/AndroidManifest.xml @@ -12,8 +12,9 @@ android:theme="@style/AppTheme"> + android:exported="true" + android:label="@string/app_name" + android:theme="@style/AppTheme.NoActionBar"> @@ -21,6 +22,12 @@ + + + android:exported="true" + android:permission="android.permission.DUMP"> - + diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/App.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/App.kt index 21a20842..3e8f4973 100644 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/App.kt +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/App.kt @@ -1,18 +1,24 @@ package cash.z.ecc.android.sdk.demoapp import androidx.multidex.MultiDexApplication -import cash.z.ecc.android.sdk.internal.TroubleshootingTwig -import cash.z.ecc.android.sdk.internal.Twig +import cash.z.ecc.android.sdk.demoapp.util.Twig class App : MultiDexApplication() { override fun onCreate() { super.onCreate() + Twig.initialize(applicationContext) + Twig.info { "Starting application…" } + if (BuildConfig.DEBUG) { StrictModeHelper.enableStrictMode() - } - Twig.plant(TroubleshootingTwig()) + // This is an internal API to the Zcash SDK to enable logging; it could change in the future + cash.z.ecc.android.sdk.internal.Twig.enabled(true) + } else { + // In release builds, logs should be stripped by R8 rules + Twig.assertLoggingStripped() + } } } diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ComposeActivity.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ComposeActivity.kt new file mode 100644 index 00000000..1366ad0a --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ComposeActivity.kt @@ -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() + + 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() + } + } + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/MainActivity.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/MainActivity.kt index 9211ee1e..c8436cb7 100644 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/MainActivity.kt +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/MainActivity.kt @@ -20,7 +20,7 @@ import androidx.navigation.ui.navigateUp import androidx.navigation.ui.setupActionBarWithNavController import androidx.navigation.ui.setupWithNavController import androidx.viewbinding.ViewBinding -import cash.z.ecc.android.sdk.demoapp.util.fromResources +import cash.z.ecc.android.sdk.demoapp.type.fromResources import cash.z.ecc.android.sdk.internal.service.LightWalletGrpcService import cash.z.ecc.android.sdk.internal.service.LightWalletService import cash.z.ecc.android.sdk.internal.twig @@ -109,6 +109,9 @@ class MainActivity : navController.navigate(R.id.nav_home) sharedViewModel.resetSDK() true + } else if (item.itemId == R.id.action_new_ui) { + startActivity(Intent(this, ComposeActivity::class.java)) + true } else { super.onOptionsItemSelected(item) } diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/Navigation.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/Navigation.kt new file mode 100644 index 00000000..935b9023 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/Navigation.kt @@ -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() + + 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" +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/SharedViewModel.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/SharedViewModel.kt index dfecc003..b478e624 100644 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/SharedViewModel.kt +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/SharedViewModel.kt @@ -6,7 +6,7 @@ import androidx.lifecycle.viewModelScope import cash.z.ecc.android.bip39.Mnemonics import cash.z.ecc.android.bip39.toSeed import cash.z.ecc.android.sdk.Synchronizer -import cash.z.ecc.android.sdk.demoapp.util.fromResources +import cash.z.ecc.android.sdk.demoapp.type.fromResources import cash.z.ecc.android.sdk.ext.BenchmarkingExt import cash.z.ecc.android.sdk.ext.onFirst import cash.z.ecc.android.sdk.fixture.BlockRangeFixture diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/WalletCoordinator.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/WalletCoordinator.kt new file mode 100644 index 00000000..c38752fb --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/WalletCoordinator.kt @@ -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) { + + 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(null) + + private sealed class InternalSynchronizerStatus { + object NoWallet : InternalSynchronizerStatus() + class Available(val synchronizer: Synchronizer) : InternalSynchronizerStatus() + class Lockout(val id: UUID) : InternalSynchronizerStatus() + } + + private val synchronizerOrLockoutId: Flow> = 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 { + 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 = 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() + .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 +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/WalletCoordinatorFactory.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/WalletCoordinatorFactory.kt new file mode 100644 index 00000000..142b1154 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/WalletCoordinatorFactory.kt @@ -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 { + /* + * 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) diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getaddress/GetAddressFragment.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getaddress/GetAddressFragment.kt index 462f9879..babd24d4 100644 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getaddress/GetAddressFragment.kt +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getaddress/GetAddressFragment.kt @@ -9,8 +9,8 @@ import androidx.lifecycle.repeatOnLifecycle import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetAddressBinding import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext +import cash.z.ecc.android.sdk.demoapp.type.fromResources import cash.z.ecc.android.sdk.demoapp.util.ProvideAddressBenchmarkTrace -import cash.z.ecc.android.sdk.demoapp.util.fromResources import cash.z.ecc.android.sdk.model.ZcashNetwork import cash.z.ecc.android.sdk.tool.DerivationTool import cash.z.ecc.android.sdk.type.UnifiedFullViewingKey diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getbalance/GetBalanceFragment.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getbalance/GetBalanceFragment.kt index ed48ac5e..6f642311 100644 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getbalance/GetBalanceFragment.kt +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getbalance/GetBalanceFragment.kt @@ -15,8 +15,8 @@ import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment import cash.z.ecc.android.sdk.demoapp.R import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetBalanceBinding import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext +import cash.z.ecc.android.sdk.demoapp.type.fromResources import cash.z.ecc.android.sdk.demoapp.util.SyncBlockchainBenchmarkTrace -import cash.z.ecc.android.sdk.demoapp.util.fromResources import cash.z.ecc.android.sdk.ext.ZcashSdk import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString import cash.z.ecc.android.sdk.internal.twig diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getblock/GetBlockFragment.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getblock/GetBlockFragment.kt index 2414716b..e2878516 100644 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getblock/GetBlockFragment.kt +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getblock/GetBlockFragment.kt @@ -7,7 +7,7 @@ import androidx.core.text.HtmlCompat import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetBlockBinding import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext -import cash.z.ecc.android.sdk.demoapp.util.fromResources +import cash.z.ecc.android.sdk.demoapp.type.fromResources import cash.z.ecc.android.sdk.demoapp.util.mainActivity import cash.z.ecc.android.sdk.demoapp.util.toHtml import cash.z.ecc.android.sdk.demoapp.util.toRelativeTime diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getblockrange/GetBlockRangeFragment.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getblockrange/GetBlockRangeFragment.kt index 4c59bacd..2ae4c968 100644 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getblockrange/GetBlockRangeFragment.kt +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getblockrange/GetBlockRangeFragment.kt @@ -8,7 +8,7 @@ import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment import cash.z.ecc.android.sdk.demoapp.R import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetBlockRangeBinding import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext -import cash.z.ecc.android.sdk.demoapp.util.fromResources +import cash.z.ecc.android.sdk.demoapp.type.fromResources import cash.z.ecc.android.sdk.demoapp.util.mainActivity import cash.z.ecc.android.sdk.demoapp.util.toRelativeTime import cash.z.ecc.android.sdk.demoapp.util.withCommas diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getprivatekey/GetPrivateKeyFragment.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getprivatekey/GetPrivateKeyFragment.kt index 6eac35f4..9b51785e 100644 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getprivatekey/GetPrivateKeyFragment.kt +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getprivatekey/GetPrivateKeyFragment.kt @@ -10,7 +10,7 @@ import cash.z.ecc.android.bip39.toSeed import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetPrivateKeyBinding import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext -import cash.z.ecc.android.sdk.demoapp.util.fromResources +import cash.z.ecc.android.sdk.demoapp.type.fromResources import cash.z.ecc.android.sdk.model.Account import cash.z.ecc.android.sdk.model.ZcashNetwork import cash.z.ecc.android.sdk.tool.DerivationTool diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/listutxos/ListUtxosFragment.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/listutxos/ListUtxosFragment.kt index ff535d45..5fcb76ee 100644 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/listutxos/ListUtxosFragment.kt +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/listutxos/ListUtxosFragment.kt @@ -14,7 +14,7 @@ import cash.z.ecc.android.sdk.block.CompactBlockProcessor import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment import cash.z.ecc.android.sdk.demoapp.databinding.FragmentListUtxosBinding import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext -import cash.z.ecc.android.sdk.demoapp.util.fromResources +import cash.z.ecc.android.sdk.demoapp.type.fromResources import cash.z.ecc.android.sdk.demoapp.util.mainActivity import cash.z.ecc.android.sdk.internal.twig import cash.z.ecc.android.sdk.model.Account diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ext/StringExt.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ext/StringExt.kt new file mode 100644 index 00000000..6f55967a --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ext/StringExt.kt @@ -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 diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ext/ui/ZatoshiExt.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ext/ui/ZatoshiExt.kt new file mode 100644 index 00000000..f39571cb --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ext/ui/ZatoshiExt.kt @@ -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) +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/fixture/WalletAddressFixture.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/fixture/WalletAddressFixture.kt new file mode 100644 index 00000000..360615c9 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/fixture/WalletAddressFixture.kt @@ -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) +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/fixture/WalletAddressesFixture.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/fixture/WalletAddressesFixture.kt new file mode 100644 index 00000000..c5313a51 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/fixture/WalletAddressesFixture.kt @@ -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) + ) +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/fixture/WalletFixture.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/fixture/WalletFixture.kt new file mode 100644 index 00000000..bc265ffd --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/fixture/WalletFixture.kt @@ -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) diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/fixture/WalletSnapshotFixture.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/fixture/WalletSnapshotFixture.kt new file mode 100644 index 00000000..107a4b22 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/fixture/WalletSnapshotFixture.kt @@ -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 + ) +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/CurrencyConversion.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/CurrencyConversion.kt new file mode 100644 index 00000000..b374a471 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/CurrencyConversion.kt @@ -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. + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/FiatCurrencyConversionRateState.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/FiatCurrencyConversionRateState.kt new file mode 100644 index 00000000..30b402e0 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/FiatCurrencyConversionRateState.kt @@ -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 + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/Locale.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/Locale.kt new file mode 100644 index 00000000..f96e00de --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/Locale.kt @@ -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) +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/Memo.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/Memo.kt new file mode 100644 index 00000000..b89304db --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/Memo.kt @@ -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 + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/PercentDecimal.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/PercentDecimal.kt new file mode 100644 index 00000000..d4e4f355 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/PercentDecimal.kt @@ -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) + ) + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/PersistableWallet.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/PersistableWallet.kt new file mode 100644 index 00000000..ecdd5fd3 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/PersistableWallet.kt @@ -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() }) diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/SeedPhrase.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/SeedPhrase.kt new file mode 100644 index 00000000..04bd5ce9 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/SeedPhrase.kt @@ -0,0 +1,25 @@ +package cash.z.ecc.android.sdk.demoapp.model + +// Consider using ImmutableList here +data class SeedPhrase(val split: List) { + 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)) + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/WalletAddress.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/WalletAddress.kt new file mode 100644 index 00000000..451d113a --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/WalletAddress.kt @@ -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() +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/WalletAddresses.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/WalletAddresses.kt new file mode 100644 index 00000000..667f42f8 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/WalletAddresses.kt @@ -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 + ) + } + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/ZecSend.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/ZecSend.kt new file mode 100644 index 00000000..2434e015 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/ZecSend.kt @@ -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 +) diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/ZecSendExt.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/ZecSendExt.kt new file mode 100644 index 00000000..c83685bc --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/ZecSendExt.kt @@ -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) : ZecSendValidation() { + enum class ValidationError { + INVALID_ADDRESS, + INVALID_AMOUNT, + INVALID_MEMO + } + } + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/ZecString.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/ZecString.kt new file mode 100644 index 00000000..0980cc47 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/ZecString.kt @@ -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 { + 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 + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/ZecStringExt.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/ZecStringExt.kt new file mode 100644 index 00000000..9f366e64 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/model/ZecStringExt.kt @@ -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) + ) + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/AndroidPreferenceProvider.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/AndroidPreferenceProvider.kt new file mode 100644 index 00000000..51bdad44 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/AndroidPreferenceProvider.kt @@ -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 = callbackFlow { + 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) + } + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/EncryptedPreferenceKeys.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/EncryptedPreferenceKeys.kt new file mode 100644 index 00000000..b2945dc9 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/EncryptedPreferenceKeys.kt @@ -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")) +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/EncryptedPreferenceSingleton.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/EncryptedPreferenceSingleton.kt new file mode 100644 index 00000000..7b322bc4 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/EncryptedPreferenceSingleton.kt @@ -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 { + AndroidPreferenceProvider.newEncrypted(it, PREF_FILENAME) + } + + suspend fun getInstance(context: Context) = lazy.getInstance(context) +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/PersistableWalletPreferenceDefault.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/PersistableWalletPreferenceDefault.kt new file mode 100644 index 00000000..15a8141e --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/PersistableWalletPreferenceDefault.kt @@ -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 { + + 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()) +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/api/PreferenceProvider.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/api/PreferenceProvider.kt new file mode 100644 index 00000000..e4abc31a --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/api/PreferenceProvider.kt @@ -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 +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/BooleanPreferenceDefault.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/BooleanPreferenceDefault.kt new file mode 100644 index 00000000..943c354a --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/BooleanPreferenceDefault.kt @@ -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 { + + @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()) + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/IntegerPreferenceDefault.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/IntegerPreferenceDefault.kt new file mode 100644 index 00000000..63f0176c --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/IntegerPreferenceDefault.kt @@ -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 { + + 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()) + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/Key.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/Key.kt new file mode 100644 index 00000000..3d041fe9 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/Key.kt @@ -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 + } + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/PreferenceDefault.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/PreferenceDefault.kt new file mode 100644 index 00000000..60b594ca --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/PreferenceDefault.kt @@ -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 { + + 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 = preferenceProvider.observe(key) + .map { getValue(preferenceProvider) } + .distinctUntilChanged() +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/StringPreferenceDefault.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/StringPreferenceDefault.kt new file mode 100644 index 00000000..35b41d34 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/preference/model/entry/StringPreferenceDefault.kt @@ -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 { + + override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key) + ?: defaultValue + + override suspend fun putValue(preferenceProvider: PreferenceProvider, newValue: String) { + preferenceProvider.putString(key, newValue) + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/type/ZcashNetwork.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/type/ZcashNetwork.kt new file mode 100644 index 00000000..11a605a3 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/type/ZcashNetwork.kt @@ -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 + } diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/common/Constants.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/common/Constants.kt new file mode 100644 index 00000000..51a13bc4 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/common/Constants.kt @@ -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 diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/common/FlowExt.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/common/FlowExt.kt new file mode 100644 index 00000000..bc7af51d --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/common/FlowExt.kt @@ -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 Flow.throttle( + duration: Duration, + timeSource: TimeSource = TimeSource.Monotonic +): Flow = flow { + coroutineScope { + val context = coroutineContext + val mutex = Mutex() + + var timeMark = timeSource.markNow() + var delayEmit: Deferred? = 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() + } + } + } + } + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/addresses/view/AddressesView.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/addresses/view/AddressesView.kt new file mode 100644 index 00000000..4ca44872 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/addresses/view/AddressesView.kt @@ -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) + } + ) + } + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/home/view/HomeView.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/home/view/HomeView.kt new file mode 100644 index 00000000..b6a2af32 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/home/view/HomeView.kt @@ -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)) + } + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/home/viewmodel/WalletSnapshot.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/home/viewmodel/WalletSnapshot.kt new file mode 100644 index 00000000..7a565044 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/home/viewmodel/WalletSnapshot.kt @@ -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 diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/home/viewmodel/WalletViewModel.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/home/viewmodel/WalletViewModel.kt new file mode 100644 index 00000000..a66dfcc6 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/home/viewmodel/WalletViewModel.kt @@ -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 = 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() + .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 = 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 = 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() + + 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() + + 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 = 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? + ) + } diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/seed/view/ConfigureSeedView.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/seed/view/ConfigureSeedView.kt new file mode 100644 index 00000000..61acbee9 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/seed/view/ConfigureSeedView.kt @@ -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)) + } + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/send/view/SendView.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/send/view/SendView.kt new file mode 100644 index 00000000..ee96c5c8 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/send/view/SendView.kt @@ -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>(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)) + } + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/AndroidApiVersion.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/AndroidApiVersion.kt new file mode 100644 index 00000000..cebf4cdc --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/AndroidApiVersion.kt @@ -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 +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/LazyWithArgument.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/LazyWithArgument.kt new file mode 100644 index 00000000..9293432d --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/LazyWithArgument.kt @@ -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(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!! + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/SampleStorage.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/SampleStorage.kt deleted file mode 100644 index 0e40d51c..00000000 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/SampleStorage.kt +++ /dev/null @@ -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" - } -} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/SimpleMnemonics.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/SimpleMnemonics.kt deleted file mode 100644 index beb024ec..00000000 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/SimpleMnemonics.kt +++ /dev/null @@ -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 = MnemonicCode(WordCount.COUNT_24).words - override fun nextMnemonicList(seed: ByteArray): List = MnemonicCode(seed).words - override fun toSeed(mnemonic: CharArray): ByteArray = MnemonicCode(mnemonic).toSeed() - override fun toWordList(mnemonic: CharArray): List = MnemonicCode(mnemonic).words -} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/SuspendingLazy.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/SuspendingLazy.kt new file mode 100644 index 00000000..a23f508c --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/SuspendingLazy.kt @@ -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(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 + } + } +} diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/Twig.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/Twig.kt new file mode 100644 index 00000000..b8d4bbb7 --- /dev/null +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/util/Twig.kt @@ -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: .(): + */ + 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 +} diff --git a/demo-app/src/main/res/menu/main.xml b/demo-app/src/main/res/menu/main.xml index 249c2f40..68e5a520 100644 --- a/demo-app/src/main/res/menu/main.xml +++ b/demo-app/src/main/res/menu/main.xml @@ -1,10 +1,8 @@ - - + + diff --git a/demo-app/src/main/res/values/bools.xml b/demo-app/src/main/res/values/bools.xml new file mode 100644 index 00000000..1ab4feaf --- /dev/null +++ b/demo-app/src/main/res/values/bools.xml @@ -0,0 +1,6 @@ + + + + false + diff --git a/demo-app/src/main/res/values/strings-regex.xml b/demo-app/src/main/res/values/strings-regex.xml new file mode 100644 index 00000000..14b3c37a --- /dev/null +++ b/demo-app/src/main/res/values/strings-regex.xml @@ -0,0 +1,4 @@ + + ^([0-9]*([0-9]+([%1$s]$|[%1$s][0-9]+))*([%2$s]$|[%2$s][0-9]+)?)?$ + ^([0-9]{1,3}(?:[%1$s]?[0-9]{3})*)*(?:[0-9]*[%2$s][0-9]*)?$ + diff --git a/demo-app/src/main/res/values/strings.xml b/demo-app/src/main/res/values/strings.xml index 2223dbb9..1819dca8 100644 --- a/demo-app/src/main/res/values/strings.xml +++ b/demo-app/src/main/res/values/strings.xml @@ -8,6 +8,8 @@ Testnet Faucet Reset SDK + New UI + Home Get Private Key @@ -31,4 +33,29 @@ Sapling address Transparent address Shield Funds + + Zcash unified address + Zcash sapling address + Zcash transparent address + + Please select your wallet secret phrase + Alyssa P. Hacker + Ben Bitdiddle + Type in a secret phrase + Generate a new random secret phrase + + Amount + + Alyssa’s unified address + Alyssa’s sapling address + Alyssa’s transparent address + + Ben’s unified address + Ben’s sapling address + Ben’s transparent address + + Available Sapling balance: + Destination address + Memo + Send diff --git a/gradle.properties b/gradle.properties index 9da11bd5..b21c34ef 100644 --- a/gradle.properties +++ b/gradle.properties @@ -86,19 +86,25 @@ KSP_VERSION=1.7.21-1.0.8 PROTOBUF_GRADLE_PLUGIN_VERSION=0.8.19 RUST_GRADLE_PLUGIN_VERSION=0.9.3 +ANDROIDX_ACTIVITY_VERSION=1.6.1 ANDROIDX_ANNOTATION_VERSION=1.5.0 ANDROIDX_APPCOMPAT_VERSION=1.5.1 +ANDROIDX_COMPOSE_COMPILER_VERSION=1.4.0-alpha02 +ANDROIDX_COMPOSE_MATERIAL3_VERSION=1.1.0-alpha02 +ANDROIDX_COMPOSE_VERSION=1.3.1 ANDROIDX_CONSTRAINT_LAYOUT_VERSION=2.1.4 ANDROIDX_CORE_VERSION=1.9.0 ANDROIDX_DATABASE_VERSION=2.2.0 ANDROIDX_ESPRESSO_VERSION=3.5.0 -ANDROIDX_LIFECYCLE_VERSION=2.5.1 +ANDROIDX_LIFECYCLE_VERSION=2.6.0-alpha03 ANDROIDX_MULTIDEX_VERSION=2.0.1 ANDROIDX_NAVIGATION_VERSION=2.5.3 +ANDROIDX_NAVIGATION_COMPOSE_VERSION=2.5.3 ANDROIDX_NAVIGATION_FRAGMENT_VERSION=2.4.2 ANDROIDX_PAGING_VERSION=2.1.2 ANDROIDX_PROFILE_INSTALLER_VERSION=1.3.0-alpha02 ANDROIDX_ROOM_VERSION=2.4.3 +ANDROIDX_SECURITY_CRYPTO_VERSION=1.1.0-alpha04 ANDROIDX_TEST_JUNIT_VERSION=1.1.4 ANDROIDX_TEST_MACROBENCHMARK_VERSION=1.2.0-alpha08 ANDROIDX_TEST_ORCHESTRATOR_VERSION=1.4.2 @@ -115,6 +121,7 @@ JACOCO_VERSION=0.8.8 JAVAX_ANNOTATION_VERSION=1.3.2 JUNIT_VERSION=5.9.1 KOTLINX_COROUTINES_VERSION=1.6.4 +KOTLINX_DATETIME_VERSION=0.4.0 KOTLIN_VERSION=1.7.21 MOCKITO_KOTLIN_VERSION=2.2.0 MOCKITO_VERSION=4.9.0 diff --git a/settings.gradle.kts b/settings.gradle.kts index e436ee12..ccd3f75f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -65,8 +65,12 @@ dependencyResolutionManagement { versionCatalogs { create("libs") { val androidGradlePluginVersion = extra["ANDROID_GRADLE_PLUGIN_VERSION"].toString() + val androidxActivityVersion = extra["ANDROIDX_ACTIVITY_VERSION"].toString() val androidxAnnotationVersion = extra["ANDROIDX_ANNOTATION_VERSION"].toString() val androidxAppcompatVersion = extra["ANDROIDX_APPCOMPAT_VERSION"].toString() + val androidxComposeCompilerVersion = extra["ANDROIDX_COMPOSE_COMPILER_VERSION"].toString() + val androidxComposeMaterial3Version = extra["ANDROIDX_COMPOSE_MATERIAL3_VERSION"].toString() + val androidxComposeVersion = extra["ANDROIDX_COMPOSE_VERSION"].toString() val androidxConstraintLayoutVersion = extra["ANDROIDX_CONSTRAINT_LAYOUT_VERSION"].toString() val androidxCoreVersion = extra["ANDROIDX_CORE_VERSION"].toString() val androidxDatabaseVersion = extra["ANDROIDX_DATABASE_VERSION"].toString() @@ -74,10 +78,12 @@ dependencyResolutionManagement { val androidxLifecycleVersion = extra["ANDROIDX_LIFECYCLE_VERSION"].toString() val androidxMultidexVersion = extra["ANDROIDX_MULTIDEX_VERSION"].toString() val androidxNavigationVersion = extra["ANDROIDX_NAVIGATION_VERSION"].toString() + val androidxNavigationComposeVersion = extra["ANDROIDX_NAVIGATION_COMPOSE_VERSION"].toString() val androidxNavigationFragmentVersion = extra["ANDROIDX_NAVIGATION_FRAGMENT_VERSION"].toString() val androidxPagingVersion = extra["ANDROIDX_PAGING_VERSION"].toString() val androidxProfileInstallerVersion = extra["ANDROIDX_PROFILE_INSTALLER_VERSION"].toString() val androidxRoomVersion = extra["ANDROIDX_ROOM_VERSION"].toString() + val androidxSecurityCryptoVersion = extra["ANDROIDX_SECURITY_CRYPTO_VERSION"].toString() val androidxTestJunitVersion = extra["ANDROIDX_TEST_JUNIT_VERSION"].toString() val androidxTestMacrobenchmarkVersion = extra["ANDROIDX_TEST_MACROBENCHMARK_VERSION"].toString() val androidxTestOrchestratorVersion = extra["ANDROIDX_TEST_ORCHESTRATOR_VERSION"].toString() @@ -96,6 +102,7 @@ dependencyResolutionManagement { val junitVersion = extra["JUNIT_VERSION"].toString() val kotlinVersion = extra["KOTLIN_VERSION"].toString() val kotlinxCoroutinesVersion = extra["KOTLINX_COROUTINES_VERSION"].toString() + val kotlinxDateTimeVersion = extra["KOTLINX_DATETIME_VERSION"].toString() val mockitoKotlinVersion = extra["MOCKITO_KOTLIN_VERSION"].toString() val mockitoVersion = extra["MOCKITO_VERSION"].toString() val protocVersion = extra["PROTOC_VERSION"].toString() @@ -121,12 +128,15 @@ dependencyResolutionManagement { // Libraries library("androidx-annotation", "androidx.annotation:annotation:$androidxAnnotationVersion") + library("androidx-activity-compose", "androidx.activity:activity-compose:$androidxActivityVersion") library("androidx-appcompat", "androidx.appcompat:appcompat:$androidxAppcompatVersion") library("androidx-constraintlayout", "androidx.constraintlayout:constraintlayout:$androidxConstraintLayoutVersion") library("androidx-core", "androidx.core:core-ktx:$androidxCoreVersion") library("androidx-lifecycle-common", "androidx.lifecycle:lifecycle-common-java8:$androidxLifecycleVersion") + library("androidx-lifecycle-compose", "androidx.lifecycle:lifecycle-runtime-compose:$androidxLifecycleVersion") library("androidx-lifecycle-runtime", "androidx.lifecycle:lifecycle-runtime-ktx:$androidxLifecycleVersion") library("androidx-multidex", "androidx.multidex:multidex:$androidxMultidexVersion") + library("androidx-navigation-compose", "androidx.navigation:navigation-compose:$androidxNavigationComposeVersion") library("androidx-navigation-fragment", "androidx.navigation:navigation-fragment-ktx:$androidxNavigationFragmentVersion") library("androidx-navigation-ui", "androidx.navigation:navigation-ui-ktx:$androidxNavigationVersion") library("androidx-paging", "androidx.paging:paging-runtime-ktx:$androidxPagingVersion") @@ -135,6 +145,7 @@ dependencyResolutionManagement { library("androidx-room-core", "androidx.room:room-ktx:$androidxRoomVersion") library("androidx-sqlite", "androidx.sqlite:sqlite-ktx:${androidxDatabaseVersion}") library("androidx-sqlite-framework", "androidx.sqlite:sqlite-framework:${androidxDatabaseVersion}") + library("androidx-viewmodel-compose", "androidx.lifecycle:lifecycle-viewmodel-compose:$androidxLifecycleVersion") library("bip39", "cash.z.ecc.android:kotlin-bip39:$bip39Version") library("grpc-android", "io.grpc:grpc-android:$grpcVersion") library("grpc-okhttp", "io.grpc:grpc-okhttp:$grpcVersion") @@ -147,9 +158,21 @@ dependencyResolutionManagement { library("kotlin-stdlib", "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") library("kotlinx-coroutines-android", "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion") library("kotlinx-coroutines-core", "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinxCoroutinesVersion") + library("kotlinx-datetime", "org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion") library("material", "com.google.android.material:material:$googleMaterialVersion") library("zcashwalletplgn", "com.github.zcash:zcash-android-wallet-plugins:$zcashWalletPluginVersion") + // Demo app + library("androidx-compose-foundation", "androidx.compose.foundation:foundation:$androidxComposeVersion") + library("androidx-compose-material3", "androidx.compose.material3:material3:$androidxComposeMaterial3Version") + library("androidx-compose-material-icons-core", "androidx.compose.material:material-icons-core:$androidxComposeVersion") + library("androidx-compose-material-icons-extended", "androidx.compose.material:material-icons-extended:$androidxComposeVersion") + library("androidx-compose-tooling", "androidx.compose.ui:ui-tooling:$androidxComposeVersion") + library("androidx-compose-ui", "androidx.compose.ui:ui:$androidxComposeVersion") + library("androidx-compose-ui-fonts", "androidx.compose.ui:ui-text-google-fonts:$androidxComposeVersion") + library("androidx-compose-compiler", "androidx.compose.compiler:compiler:$androidxComposeCompilerVersion") + library("androidx-security-crypto", "androidx.security:security-crypto-ktx:$androidxSecurityCryptoVersion") + // Test libraries library("androidx-espresso-contrib", "androidx.test.espresso:espresso-contrib:$androidxEspressoVersion") library("androidx-espresso-core", "androidx.test.espresso:espresso-core:$androidxEspressoVersion") @@ -173,16 +196,6 @@ dependencyResolutionManagement { library("mockito-kotlin", "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockitoKotlinVersion") // Bundles - bundle( - "androidx-test", - listOf( - "androidx-espresso-core", - "androidx-espresso-intents", - "androidx-test-junit", - "androidx-test-core" - ) - ) - bundle( "grpc", listOf( @@ -193,6 +206,39 @@ dependencyResolutionManagement { ) ) + bundle( + "androidx-compose-core", + listOf( + "androidx-compose-compiler", + "androidx-compose-foundation", + "androidx-compose-material3", + "androidx-compose-tooling", + "androidx-compose-ui", + "androidx-compose-ui-fonts" + ) + ) + bundle( + "androidx-compose-extended", + listOf( + "androidx-activity-compose", + "androidx-compose-material-icons-core", + "androidx-compose-material-icons-extended", + "androidx-lifecycle-compose", + "androidx-navigation-compose", + "androidx-viewmodel-compose" + ) + ) + + bundle( + "androidx-test", + listOf( + "androidx-espresso-core", + "androidx-espresso-intents", + "androidx-test-junit", + "androidx-test-core" + ) + ) + bundle( "junit", listOf( diff --git a/tools/detekt.yml b/tools/detekt.yml index 45250bb4..b8afc95c 100644 --- a/tools/detekt.yml +++ b/tools/detekt.yml @@ -333,6 +333,8 @@ naming: functionPattern: '[a-z][a-zA-Z0-9]*' excludeClassPattern: '$^' ignoreOverridden: true + ignoreAnnotated: + - 'Composable' FunctionParameterNaming: active: true parameterPattern: '[a-z][A-Za-z0-9]*'