diff --git a/README.md b/README.md index 9ae80de7..3f8db40a 100644 --- a/README.md +++ b/README.md @@ -35,4 +35,5 @@ If you plan to fork the project to create a new app of your own, please make the 1. When the code coverage Gradle property `IS_COVERAGE_ENABLED` is enabled, the debug app APK cannot be run. The coverage flag should therefore only be set when running automated tests. 1. Test coverage for Compose code will be low, due to [known limitations](https://github.com/jacoco/jacoco/issues/1208) in the interaction between Compose and Jacoco. 1. Adding the `espresso-contrib` dependency will cause builds to fail, due to conflicting classes. This is a [known issue](https://github.com/zcash/zcash-android-wallet-sdk/issues/306) with the Zcash Android SDK. -1. Android Studio will warn about the Gradle checksum. This is a [known issue](https://github.com/gradle/gradle/issues/9361) and can be safely ignored. \ No newline at end of file +1. Android Studio will warn about the Gradle checksum. This is a [known issue](https://github.com/gradle/gradle/issues/9361) and can be safely ignored. +1. During app first launch, the following exception starting with `AndroidKeysetManager: keyset not found, will generate a new one` is printed twice. This exception is not an error, and the code is not being invoked twice. \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3961d71b..639c4f4c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,7 +17,7 @@ clients. --> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7335191b..48ecd2b7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ - Demo App + Zcash Wallet diff --git a/app/src/zcashmainnet/res/values/strings.xml b/app/src/zcashmainnet/res/values/strings.xml deleted file mode 100644 index b05223d2..00000000 --- a/app/src/zcashmainnet/res/values/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - Mainnet Demo - Mainnet - diff --git a/app/src/zcashtestnet/res/values/bools.xml b/app/src/zcashtestnet/res/values/bools.xml new file mode 100644 index 00000000..a775053c --- /dev/null +++ b/app/src/zcashtestnet/res/values/bools.xml @@ -0,0 +1,5 @@ + + + + true + diff --git a/app/src/zcashtestnet/res/values/strings.xml b/app/src/zcashtestnet/res/values/strings.xml index fe2c3908..68d296bf 100644 --- a/app/src/zcashtestnet/res/values/strings.xml +++ b/app/src/zcashtestnet/res/values/strings.xml @@ -1,4 +1,4 @@ - Testnet Demo + Zcash Testnet Wallet Testnet diff --git a/preference-impl-android-lib/src/androidTest/java/co/electriccoin/zcash/preference/EncryptedPreferenceProviderTest.kt b/preference-impl-android-lib/src/androidTest/java/co/electriccoin/zcash/preference/EncryptedPreferenceProviderTest.kt index 4ae7d0b7..070ff3e2 100644 --- a/preference-impl-android-lib/src/androidTest/java/co/electriccoin/zcash/preference/EncryptedPreferenceProviderTest.kt +++ b/preference-impl-android-lib/src/androidTest/java/co/electriccoin/zcash/preference/EncryptedPreferenceProviderTest.kt @@ -81,6 +81,6 @@ class EncryptedPreferenceProviderTest { companion object { private val FILENAME = "encrypted_preference_test" - private suspend fun new() = EncryptedPreferenceProvider.new(ApplicationProvider.getApplicationContext(), FILENAME) + private suspend fun new() = AndroidPreferenceProvider.newEncrypted(ApplicationProvider.getApplicationContext(), FILENAME) } } diff --git a/preference-impl-android-lib/src/androidTest/java/co/electriccoin/zcash/preference/StandardPreferenceProviderTest.kt b/preference-impl-android-lib/src/androidTest/java/co/electriccoin/zcash/preference/StandardPreferenceProviderTest.kt new file mode 100644 index 00000000..e9d8f4b0 --- /dev/null +++ b/preference-impl-android-lib/src/androidTest/java/co/electriccoin/zcash/preference/StandardPreferenceProviderTest.kt @@ -0,0 +1,67 @@ +package co.electriccoin.zcash.preference + +import androidx.test.core.app.ApplicationProvider +import androidx.test.filters.SmallTest +import co.electriccoin.zcash.preference.test.fixture.StringDefaultPreferenceFixture +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +// Areas that are not covered yet: +// 1. Test observer behavior +class StandardPreferenceProviderTest { + /* + * Note: This test relies on Test Orchestrator to avoid issues with multiple runs. Specifically, + * it purges the preference file and avoids corruption due to multiple instances of the + * EncryptedPreferenceProvider. + */ + + private var isRun = false + + @Before + fun checkUsingOrchestrator() { + check(!isRun) { "State appears to be retained between test method invocations; verify that Test Orchestrator is enabled and then re-run the tests" } + + isRun = true + } + + @Test + @SmallTest + fun put_and_get_string() = runBlocking { + val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra" + + val preferenceProvider = new().apply { + putString(StringDefaultPreferenceFixture.KEY, expectedValue) + } + + assertEquals(expectedValue, StringDefaultPreferenceFixture.new().getValue(preferenceProvider)) + } + + @Test + @SmallTest + fun hasKey_false() = runBlocking { + val preferenceProvider = new() + + assertFalse(preferenceProvider.hasKey(StringDefaultPreferenceFixture.new().key)) + } + + @Test + @SmallTest + fun put_and_check_key() = runBlocking { + val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra" + + val preferenceProvider = new().apply { + putString(StringDefaultPreferenceFixture.KEY, expectedValue) + } + + assertTrue(preferenceProvider.hasKey(StringDefaultPreferenceFixture.new().key)) + } + + companion object { + private val FILENAME = "encrypted_preference_test" + private suspend fun new() = AndroidPreferenceProvider.newStandard(ApplicationProvider.getApplicationContext(), FILENAME) + } +} diff --git a/preference-impl-android-lib/src/main/java/co/electriccoin/zcash/preference/EncryptedPreferenceProvider.kt b/preference-impl-android-lib/src/main/java/co/electriccoin/zcash/preference/AndroidPreferenceProvider.kt similarity index 79% rename from preference-impl-android-lib/src/main/java/co/electriccoin/zcash/preference/EncryptedPreferenceProvider.kt rename to preference-impl-android-lib/src/main/java/co/electriccoin/zcash/preference/AndroidPreferenceProvider.kt index 99b28577..55319a3e 100644 --- a/preference-impl-android-lib/src/main/java/co/electriccoin/zcash/preference/EncryptedPreferenceProvider.kt +++ b/preference-impl-android-lib/src/main/java/co/electriccoin/zcash/preference/AndroidPreferenceProvider.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.withContext import java.util.concurrent.Executors /** - * Provides encrypted shared preferences. + * Provides an Android implementation of shared preferences. * * This class is thread-safe. * @@ -30,7 +30,7 @@ import java.util.concurrent.Executors * Implementation note: EncryptedSharedPreferences are not thread-safe, so this implementation * confines them to a single background thread. */ -class EncryptedPreferenceProvider( +class AndroidPreferenceProvider( private val sharedPreferences: SharedPreferences, private val dispatcher: CoroutineDispatcher ) : PreferenceProvider { @@ -71,7 +71,21 @@ class EncryptedPreferenceProvider( }.flowOn(dispatcher) companion object { - suspend fun new(context: Context, filename: String): PreferenceProvider { + 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. @@ -96,7 +110,7 @@ class EncryptedPreferenceProvider( ) } - return EncryptedPreferenceProvider(sharedPreferences, singleThreadedDispatcher) + return AndroidPreferenceProvider(sharedPreferences, singleThreadedDispatcher) } } } diff --git a/sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/type/ZcashNetworkTest.kt b/sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/type/ZcashNetworkTest.kt new file mode 100644 index 00000000..30f46d48 --- /dev/null +++ b/sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/type/ZcashNetworkTest.kt @@ -0,0 +1,15 @@ +package cash.z.ecc.sdk.type + +import androidx.test.core.app.ApplicationProvider +import androidx.test.filters.SmallTest +import cash.z.ecc.android.sdk.type.ZcashNetwork +import org.junit.Assert.assertEquals +import org.junit.Test + +class ZcashNetworkTest { + @SmallTest + @Test + fun mainnet() { + assertEquals(ZcashNetwork.Mainnet, ZcashNetwork.fromResources(ApplicationProvider.getApplicationContext())) + } +} diff --git a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/SynchronizerCompanion.kt b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/SynchronizerCompanion.kt index 515a90a4..148c057a 100644 --- a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/SynchronizerCompanion.kt +++ b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/SynchronizerCompanion.kt @@ -14,7 +14,7 @@ import kotlinx.coroutines.withContext // Synchronizer needs a Companion object // https://github.com/zcash/zcash-android-wallet-sdk/issues/310 object SynchronizerCompanion { - suspend fun load(context: Context, persistableWallet: PersistableWallet) { + suspend fun load(context: Context, persistableWallet: PersistableWallet): Synchronizer { val config = persistableWallet.toConfig() val initializer = withContext(Dispatchers.IO) { Initializer(context, config) } return withContext(Dispatchers.IO) { Synchronizer(initializer) } @@ -24,11 +24,15 @@ object SynchronizerCompanion { private suspend fun PersistableWallet.deriveViewingKey(): UnifiedViewingKey { // Dispatcher needed because SecureRandom is loaded, which is slow and performs IO // https://github.com/zcash/kotlin-bip39/issues/13 - val bip39Seed = withContext(Dispatchers.IO) { Mnemonics.MnemonicCode(seedPhrase).toSeed() } + val bip39Seed = withContext(Dispatchers.IO) { + Mnemonics.MnemonicCode(seedPhrase.phrase).toSeed() + } // Dispatchers needed until an SDK is published with the implementation of // https://github.com/zcash/zcash-android-wallet-sdk/issues/269 - val viewingKey = withContext(Dispatchers.IO) { DerivationTool.deriveUnifiedViewingKeys(bip39Seed, network)[0] } + val viewingKey = withContext(Dispatchers.IO) { + DerivationTool.deriveUnifiedViewingKeys(bip39Seed, network)[0] + } return viewingKey } diff --git a/sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/fixture/PersistableWalletFixture.kt b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/PersistableWalletFixture.kt similarity index 63% rename from sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/fixture/PersistableWalletFixture.kt rename to sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/PersistableWalletFixture.kt index 28bf5602..9accd6b4 100644 --- a/sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/fixture/PersistableWalletFixture.kt +++ b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/PersistableWalletFixture.kt @@ -3,6 +3,7 @@ package cash.z.ecc.sdk.fixture import cash.z.ecc.android.sdk.type.WalletBirthday import cash.z.ecc.android.sdk.type.ZcashNetwork import cash.z.ecc.sdk.model.PersistableWallet +import cash.z.ecc.sdk.model.SeedPhrase object PersistableWalletFixture { @@ -10,7 +11,12 @@ object PersistableWalletFixture { val BIRTHDAY = WalletBirthdayFixture.new() + @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(network: ZcashNetwork = NETWORK, birthday: WalletBirthday = BIRTHDAY, seedPhrase: String = SEED_PHRASE) = PersistableWallet(network, birthday, seedPhrase) + fun new( + network: ZcashNetwork = NETWORK, + birthday: WalletBirthday = BIRTHDAY, + seedPhrase: String = SEED_PHRASE + ) = PersistableWallet(network, birthday, SeedPhrase(seedPhrase)) } diff --git a/sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/fixture/WalletBirthdayFixture.kt b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/WalletBirthdayFixture.kt similarity index 97% rename from sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/fixture/WalletBirthdayFixture.kt rename to sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/WalletBirthdayFixture.kt index 866f546a..45eb1d09 100644 --- a/sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/fixture/WalletBirthdayFixture.kt +++ b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/WalletBirthdayFixture.kt @@ -7,6 +7,7 @@ object WalletBirthdayFixture { const val HEIGHT = 1500000 const val HASH = "00047a34c61409682f44640af9352023ad92f69b827d0f2b288f152ebea50f46" const val EPOCH_SECONDS = 1627076501L + @Suppress("MaxLineLength") const val TREE = "01172b95f271c6af8f68388f08c8ef970db8ec8d8d61204ecb7b2bb2c38262b92d0010016284585a6c85dadfef27ff33f1403926b4bb391de92e8be797e4280cc4ca2971000001a1ff388639379c0120782b3929bd8871af797be4b651f694aa961bad65a9c12400000001d806c98bda9653d5ae22757eed750871e16e0fb657f52c3d771a4411668e84330001260f6e9fac0922f98d58afbcc3f391ac19d5d944081466929a33b99df19c0e6a0000013d2fd009bf8a22d68f720eac19c411c99014ed9c5f85d5942e15d1fc039e28680001f08f39275112dd8905b854170b7f247cf2df18454d4fa94e6e4f9320cca05f24011f8322ef806eb2430dc4a7a41c1b344bea5be946efc7b4349c1c9edb14ff9d39" fun new( diff --git a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/PersistableWallet.kt b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/PersistableWallet.kt index cc31c707..1da2fa1b 100644 --- a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/PersistableWallet.kt +++ b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/PersistableWallet.kt @@ -1,7 +1,14 @@ package cash.z.ecc.sdk.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.tool.WalletBirthdayTool import cash.z.ecc.android.sdk.type.WalletBirthday import cash.z.ecc.android.sdk.type.ZcashNetwork +import cash.z.ecc.sdk.type.fromResources +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.json.JSONObject /** @@ -10,7 +17,7 @@ import org.json.JSONObject data class PersistableWallet( val network: ZcashNetwork, val birthday: WalletBirthday, - val seedPhrase: String + val seedPhrase: SeedPhrase ) { /** @@ -22,7 +29,7 @@ data class PersistableWallet( put(KEY_VERSION, VERSION_1) put(KEY_NETWORK_ID, network.id) put(KEY_BIRTHDAY, birthday.toJson()) - put(KEY_SEED_PHRASE, seedPhrase) + put(KEY_SEED_PHRASE, seedPhrase.phrase) } override fun toString(): String { @@ -45,12 +52,34 @@ data class PersistableWallet( val birthday = WalletBirthdayCompanion.from(jsonObject.getJSONObject(KEY_BIRTHDAY)) val seedPhrase = jsonObject.getString(KEY_SEED_PHRASE) - return PersistableWallet(ZcashNetwork.from(networkId), birthday, seedPhrase) + return PersistableWallet(ZcashNetwork.from(networkId), birthday, SeedPhrase(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) + // Dispatchers can be removed once a new SDK is released implementing + // https://github.com/zcash/zcash-android-wallet-sdk/issues/269 + val walletBirthday = withContext(Dispatchers.IO) { + WalletBirthdayTool.loadNearest(application, zcashNetwork) + } + val seedPhrase = newSeedPhrase() + + return PersistableWallet(zcashNetwork, walletBirthday, 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().joinToString(separator = " ") { it.concatToString() }) diff --git a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/SeedPhrase.kt b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/SeedPhrase.kt new file mode 100644 index 00000000..22c00029 --- /dev/null +++ b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/SeedPhrase.kt @@ -0,0 +1,20 @@ +package cash.z.ecc.sdk.model + +data class SeedPhrase(val phrase: String) { + val split = phrase.split(" ") + + init { + require(SEED_PHRASE_SIZE == split.size) { + "Seed phrase must split into $SEED_PHRASE_SIZE words but was ${split.size}" + } + } + + override fun toString(): String { + // For security, intentionally override the toString method to reduce risk of accidentally logging secrets + return "SeedPhrase" + } + + companion object { + const val SEED_PHRASE_SIZE = 24 + } +} diff --git a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/type/ZcashNetwork.kt b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/type/ZcashNetwork.kt new file mode 100644 index 00000000..f8d9421e --- /dev/null +++ b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/type/ZcashNetwork.kt @@ -0,0 +1,31 @@ +package cash.z.ecc.sdk.type + +import android.app.Application +import cash.z.ecc.android.sdk.type.ZcashNetwork +import cash.z.ecc.sdk.ext.R + +/* + * 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(application: Application) = + if (application.resources.getBoolean(R.bool.zcash_is_testnet)) { + ZcashNetwork.Testnet + } else { + ZcashNetwork.Mainnet + } diff --git a/sdk-ext-lib/src/main/res/values/bools.xml b/sdk-ext-lib/src/main/res/values/bools.xml new file mode 100644 index 00000000..0d45923c --- /dev/null +++ b/sdk-ext-lib/src/main/res/values/bools.xml @@ -0,0 +1,6 @@ + + + + false + \ No newline at end of file diff --git a/ui-lib/build.gradle.kts b/ui-lib/build.gradle.kts index 5499a6e1..07ac3f25 100644 --- a/ui-lib/build.gradle.kts +++ b/ui-lib/build.gradle.kts @@ -28,7 +28,8 @@ android { res.setSrcDirs( setOf( "src/main/res/ui/common", - "src/main/res/ui/onboarding" + "src/main/res/ui/onboarding", + "src/main/res/ui/new_wallet" ) ) } diff --git a/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/backup/model/BackupStageTest.kt b/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/backup/model/BackupStageTest.kt new file mode 100644 index 00000000..ef3578a0 --- /dev/null +++ b/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/backup/model/BackupStageTest.kt @@ -0,0 +1,80 @@ +package cash.z.ecc.ui.screen.backup.model + +import androidx.test.filters.SmallTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class BackupStageTest { + + @Test + @SmallTest + fun getProgress_first() { + val progress = BackupStage.values().first().getProgress() + + assertEquals(0, progress.current.value) + assertEquals(4, progress.last.value) + } + + @Test + @SmallTest + fun getProgress_last() { + val progress = BackupStage.values().last().getProgress() + + assertEquals(4, progress.current.value) + assertEquals(4, progress.last.value) + } + + @Test + @SmallTest + fun hasNext_boundary() { + val last = BackupStage.values().last() + + assertFalse(last.hasNext()) + } + + @Test + @SmallTest + fun hasPrevious_boundary() { + val last = BackupStage.values().first() + + assertFalse(last.hasPrevious()) + } + + @Test + @SmallTest + fun getNext_from_first() { + val first = BackupStage.values().first() + val next = first.getNext() + + assertNotEquals(first, next) + assertEquals(BackupStage.EducationRecoveryPhrase, next) + } + + @Test + @SmallTest + fun getNext_boundary() { + val last = BackupStage.values().last() + + assertEquals(last, last.getNext()) + } + + @Test + @SmallTest + fun getPrevious_from_last() { + val last = BackupStage.values().last() + val previous = last.getPrevious() + + assertNotEquals(last, previous) + assertEquals(BackupStage.Test, previous) + } + + @Test + @SmallTest + fun getPrevious_boundary() { + val first = BackupStage.values().first() + + assertEquals(first, first.getPrevious()) + } +} diff --git a/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/backup/view/BackupViewTest.kt b/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/backup/view/BackupViewTest.kt new file mode 100644 index 00000000..e698b60c --- /dev/null +++ b/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/backup/view/BackupViewTest.kt @@ -0,0 +1,201 @@ +package cash.z.ecc.ui.screen.backup.view + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.filters.MediumTest +import cash.z.ecc.sdk.fixture.PersistableWalletFixture +import cash.z.ecc.ui.R +import cash.z.ecc.ui.screen.backup.BackupTags +import cash.z.ecc.ui.screen.backup.model.BackupStage +import cash.z.ecc.ui.screen.backup.state.BackupState +import cash.z.ecc.ui.screen.backup.state.TestChoices +import cash.z.ecc.ui.test.getStringResource +import cash.z.ecc.ui.theme.ZcashTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class BackupViewTest { + @get:Rule + val composeTestRule = createComposeRule() + + // Sanity check the TestSetup + @Test + @MediumTest + fun verify_test_setup_stage_1() { + val testSetup = newTestSetup(BackupStage.EducationOverview) + + assertEquals(BackupStage.EducationOverview, testSetup.getStage()) + assertEquals(0, testSetup.getOnCompleteCallbackCount()) + } + + @Test + @MediumTest + fun verify_test_setup_stage_5() { + val testSetup = newTestSetup(BackupStage.Complete) + + assertEquals(BackupStage.Complete, testSetup.getStage()) + assertEquals(0, testSetup.getOnCompleteCallbackCount()) + } + + @Test + @MediumTest + fun test_pass() { + val testSetup = newTestSetup(BackupStage.Test) + + composeTestRule.onAllNodesWithTag(BackupTags.DROPDOWN_CHIP).also { + it.assertCountEquals(4) + + it[0].performClick() + composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[1].performClick() + + it[1].performClick() + composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[0].performClick() + + it[2].performClick() + composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[3].performClick() + + it[3].performClick() + composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[2].performClick() + } + + assertEquals(BackupStage.Complete, testSetup.getStage()) + } + + @Test + @MediumTest + fun test_fail() { + val testSetup = newTestSetup(BackupStage.Test) + + composeTestRule.onAllNodesWithTag(BackupTags.DROPDOWN_CHIP).also { + it.assertCountEquals(4) + + it[0].performClick() + composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[0].performClick() + + it[1].performClick() + composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[1].performClick() + + it[2].performClick() + composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[2].performClick() + + it[3].performClick() + composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[3].performClick() + } + + assertEquals(BackupStage.Test, testSetup.getStage()) + + composeTestRule.onNode(hasText(getStringResource(R.string.new_wallet_4_header_ouch))) + + composeTestRule.onNode(hasText(getStringResource(R.string.new_wallet_4_button_retry))).performClick() + + assertEquals(BackupStage.Seed, testSetup.getStage()) + } + + @Test + @MediumTest + fun last_stage_click_finish() { + val testSetup = newTestSetup(BackupStage.Complete) + + val goToWalletButton = composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_5_button_finished)) + goToWalletButton.performClick() + + assertEquals(1, testSetup.getOnCompleteCallbackCount()) + } + + @Test + @MediumTest + fun last_stage_click_back_to_seed() { + val testSetup = newTestSetup(BackupStage.Complete) + + val newWalletButton = composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_5_button_back)) + newWalletButton.performClick() + + assertEquals(0, testSetup.getOnCompleteCallbackCount()) + assertEquals(BackupStage.Seed, testSetup.getStage()) + } + + @Test + @MediumTest + fun multi_stage_progression() { + val testSetup = newTestSetup(BackupStage.EducationOverview) + + composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_1_button)).also { + it.performClick() + } + + assertEquals(BackupStage.EducationRecoveryPhrase, testSetup.getStage()) + + composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_2_button)).also { + it.performClick() + } + + assertEquals(BackupStage.Seed, testSetup.getStage()) + + composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_3_button_finished)).also { + it.performClick() + } + + assertEquals(BackupStage.Test, testSetup.getStage()) + + composeTestRule.onAllNodesWithTag(BackupTags.DROPDOWN_CHIP).also { + it.assertCountEquals(4) + + it[0].performClick() + composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[1].performClick() + + it[1].performClick() + composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[0].performClick() + + it[2].performClick() + composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[3].performClick() + + it[3].performClick() + composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[2].performClick() + } + + assertEquals(BackupStage.Complete, testSetup.getStage()) + + composeTestRule.onNode(hasText(getStringResource(R.string.new_wallet_5_button_finished))).performClick() + + assertEquals(1, testSetup.getOnCompleteCallbackCount()) + } + + private fun newTestSetup(initalStage: BackupStage) = TestSetup(composeTestRule, initalStage) + + private class TestSetup(private val composeTestRule: ComposeContentTestRule, initalStage: BackupStage) { + private val state = BackupState(initalStage) + + private var onCompleteCallbackCount = 0 + + fun getOnCompleteCallbackCount(): Int { + composeTestRule.waitForIdle() + return onCompleteCallbackCount + } + + fun getStage(): BackupStage { + composeTestRule.waitForIdle() + return state.current.value + } + + init { + composeTestRule.setContent { + ZcashTheme { + BackupWallet( + PersistableWalletFixture.new(), + state, + TestChoices(), + onComplete = { onCompleteCallbackCount++ } + ) + } + } + } + } +} diff --git a/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/onboarding/view/OnboardingViewTest.kt b/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/onboarding/view/OnboardingViewTest.kt index 8510745b..93799623 100644 --- a/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/onboarding/view/OnboardingViewTest.kt +++ b/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/onboarding/view/OnboardingViewTest.kt @@ -10,7 +10,7 @@ import androidx.test.filters.MediumTest import cash.z.ecc.ui.R import cash.z.ecc.ui.screen.onboarding.model.OnboardingStage import cash.z.ecc.ui.screen.onboarding.state.OnboardingState -import cash.z.ecc.ui.screen.onboarding.test.getStringResource +import cash.z.ecc.ui.test.getStringResource import cash.z.ecc.ui.theme.ZcashTheme import org.junit.Assert.assertEquals import org.junit.Rule diff --git a/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/onboarding/test/Global.kt b/ui-lib/src/androidTest/java/cash/z/ecc/ui/test/Global.kt similarity index 84% rename from ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/onboarding/test/Global.kt rename to ui-lib/src/androidTest/java/cash/z/ecc/ui/test/Global.kt index 6589acf8..c093cbe2 100644 --- a/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/onboarding/test/Global.kt +++ b/ui-lib/src/androidTest/java/cash/z/ecc/ui/test/Global.kt @@ -1,4 +1,4 @@ -package cash.z.ecc.ui.screen.onboarding.test +package cash.z.ecc.ui.test import android.content.Context import androidx.annotation.StringRes diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/MainActivity.kt b/ui-lib/src/main/java/cash/z/ecc/ui/MainActivity.kt index 9ed42f52..b6c50add 100644 --- a/ui-lib/src/main/java/cash/z/ecc/ui/MainActivity.kt +++ b/ui-lib/src/main/java/cash/z/ecc/ui/MainActivity.kt @@ -4,8 +4,13 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import cash.z.ecc.sdk.model.PersistableWallet +import cash.z.ecc.ui.screen.backup.view.BackupWallet +import cash.z.ecc.ui.screen.backup.viewmodel.BackupViewModel import cash.z.ecc.ui.screen.home.view.Home +import cash.z.ecc.ui.screen.home.viewmodel.WalletState import cash.z.ecc.ui.screen.home.viewmodel.WalletViewModel import cash.z.ecc.ui.screen.onboarding.view.Onboarding import cash.z.ecc.ui.screen.onboarding.viewmodel.OnboardingViewModel @@ -16,26 +21,39 @@ class MainActivity : ComponentActivity() { private val walletViewModel by viewModels() private val onboardingViewModel by viewModels() + private val backupViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { ZcashTheme { - if (null == walletViewModel.persistableWallet.collectAsState(null).value) { - // Optimized path to get to onboarding as quickly as possible - Onboarding( - onboardingState = onboardingViewModel.onboardingState, - onImportWallet = { TODO("Implement wallet import") }, - onCreateWallet = { TODO("Implement wallet create") } - ) - } else { - if (null == walletViewModel.synchronizer.collectAsState(null).value) { - // Continue displaying splash screen - } else { - Home() + when (val walletState = walletViewModel.state.collectAsState().value) { + WalletState.Loading -> { + // For now, keep displaying splash screen } + WalletState.NoWallet -> WrapOnboarding() + is WalletState.NeedsBackup -> WrapBackup(walletState.persistableWallet) + is WalletState.Ready -> Home(walletState.persistableWallet) } } } } + + @Composable + private fun WrapBackup(persistableWallet: PersistableWallet) { + BackupWallet(persistableWallet, backupViewModel.backupState, backupViewModel.testChoices) { + walletViewModel.persistBackupComplete() + } + } + + @Composable + private fun WrapOnboarding() { + Onboarding( + onboardingState = onboardingViewModel.onboardingState, + onImportWallet = { TODO("Implement wallet import") }, + onCreateWallet = { + walletViewModel.createAndPersistWallet() + } + ) + } } diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/preference/EncryptedPreferenceSingleton.kt b/ui-lib/src/main/java/cash/z/ecc/ui/preference/EncryptedPreferenceSingleton.kt index d103b167..440a9653 100644 --- a/ui-lib/src/main/java/cash/z/ecc/ui/preference/EncryptedPreferenceSingleton.kt +++ b/ui-lib/src/main/java/cash/z/ecc/ui/preference/EncryptedPreferenceSingleton.kt @@ -2,14 +2,14 @@ package cash.z.ecc.ui.preference import android.content.Context import cash.z.ecc.ui.util.Lazy -import co.electriccoin.zcash.preference.EncryptedPreferenceProvider +import co.electriccoin.zcash.preference.AndroidPreferenceProvider import co.electriccoin.zcash.preference.api.PreferenceProvider object EncryptedPreferenceSingleton { private const val PREF_FILENAME = "co.electriccoin.zcash.encrypted" - private val lazy = Lazy { EncryptedPreferenceProvider.new(it, PREF_FILENAME) } + private val lazy = Lazy { AndroidPreferenceProvider.newEncrypted(it, PREF_FILENAME) } suspend fun getInstance(context: Context) = lazy.getInstance(context) } diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/preference/StandardPreferenceKeys.kt b/ui-lib/src/main/java/cash/z/ecc/ui/preference/StandardPreferenceKeys.kt new file mode 100644 index 00000000..f0bec604 --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/preference/StandardPreferenceKeys.kt @@ -0,0 +1,12 @@ +package cash.z.ecc.ui.preference + +import co.electriccoin.zcash.preference.model.entry.BooleanPreferenceDefault +import co.electriccoin.zcash.preference.model.entry.Key + +object StandardPreferenceKeys { + + /** + * Whether the user has completed the backup flow for a newly created wallet. + */ + val IS_USER_BACKUP_COMPLETE = BooleanPreferenceDefault(Key("is_user_backup_complete"), false) +} diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/preference/StandardPreferenceSingleton.kt b/ui-lib/src/main/java/cash/z/ecc/ui/preference/StandardPreferenceSingleton.kt new file mode 100644 index 00000000..2c6c3104 --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/preference/StandardPreferenceSingleton.kt @@ -0,0 +1,15 @@ +package cash.z.ecc.ui.preference + +import android.content.Context +import cash.z.ecc.ui.util.Lazy +import co.electriccoin.zcash.preference.AndroidPreferenceProvider +import co.electriccoin.zcash.preference.api.PreferenceProvider + +object StandardPreferenceSingleton { + + private const val PREF_FILENAME = "co.electriccoin.zcash" + + private val lazy = Lazy { AndroidPreferenceProvider.newStandard(it, PREF_FILENAME) } + + suspend fun getInstance(context: Context) = lazy.getInstance(context) +} diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/BackupTags.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/BackupTags.kt new file mode 100644 index 00000000..d17aae45 --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/BackupTags.kt @@ -0,0 +1,9 @@ +package cash.z.ecc.ui.screen.backup + +/** + * These are only used for automated testing. + */ +object BackupTags { + const val DROPDOWN_CHIP = "dropdown_chip" + const val DROPDOWN_MENU = "dropdown_menu" +} diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/model/BackupStage.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/model/BackupStage.kt new file mode 100644 index 00000000..7fbf3f94 --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/model/BackupStage.kt @@ -0,0 +1,39 @@ +package cash.z.ecc.ui.screen.backup.model + +import cash.z.ecc.ui.screen.onboarding.model.Index +import cash.z.ecc.ui.screen.onboarding.model.Progress + +enum class BackupStage { + // Note: the ordinal order is used to manage progression through each stage + // so be careful if reordering these + EducationOverview, + EducationRecoveryPhrase, + Seed, + Test, + Complete; + + /** + * @see getPrevious + */ + fun hasPrevious() = ordinal > 0 + + /** + * @see getNext + */ + fun hasNext() = ordinal < values().size - 1 + + /** + * @return Previous item in ordinal order. Returns the first item when it cannot go further back. + */ + fun getPrevious() = values()[maxOf(0, ordinal - 1)] + + /** + * @return Last item in ordinal order. Returns the last item when it cannot go further forward. + */ + fun getNext() = values()[minOf(values().size - 1, ordinal + 1)] + + /** + * @return Last item in ordinal order. Returns the last item when it cannot go further forward. + */ + fun getProgress() = Progress(Index(ordinal), Index(values().size - 1)) +} diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/state/BackupState.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/state/BackupState.kt new file mode 100644 index 00000000..60f5a836 --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/state/BackupState.kt @@ -0,0 +1,31 @@ +package cash.z.ecc.ui.screen.backup.state + +import cash.z.ecc.ui.screen.backup.model.BackupStage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * @param initialState Allows restoring the state from a different starting point. This is + * primarily useful on Android, for automated tests, and for iterative debugging with the Compose + * layout preview. The default constructor argument is generally fine for other platforms. + */ +class BackupState(initialState: BackupStage = BackupStage.values().first()) { + + private val mutableState = MutableStateFlow(initialState) + + val current: StateFlow = mutableState + + fun hasNext() = current.value.hasNext() + + fun goNext() { + mutableState.value = current.value.getNext() + } + + fun goPrevious() { + mutableState.value = current.value.getPrevious() + } + + fun goToSeed() { + mutableState.value = BackupStage.Seed + } +} diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/state/TestChoices.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/state/TestChoices.kt new file mode 100644 index 00000000..9126762a --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/state/TestChoices.kt @@ -0,0 +1,15 @@ +package cash.z.ecc.ui.screen.backup.state + +import cash.z.ecc.ui.screen.onboarding.model.Index +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class TestChoices(initial: Map = emptyMap()) { + private val mutableState = MutableStateFlow>(HashMap(initial)) + + val current: StateFlow> = mutableState + + fun set(map: Map) { + mutableState.value = HashMap(map) + } +} diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/view/BackupView.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/view/BackupView.kt new file mode 100644 index 00000000..4ef2f681 --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/view/BackupView.kt @@ -0,0 +1,254 @@ +package cash.z.ecc.ui.screen.backup.view + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.Card +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import cash.z.ecc.sdk.fixture.PersistableWalletFixture +import cash.z.ecc.sdk.model.PersistableWallet +import cash.z.ecc.ui.R +import cash.z.ecc.ui.screen.backup.BackupTags +import cash.z.ecc.ui.screen.backup.model.BackupStage +import cash.z.ecc.ui.screen.backup.state.BackupState +import cash.z.ecc.ui.screen.backup.state.TestChoices +import cash.z.ecc.ui.screen.common.Body +import cash.z.ecc.ui.screen.common.CHIP_GRID_ROW_SIZE +import cash.z.ecc.ui.screen.common.Chip +import cash.z.ecc.ui.screen.common.ChipGrid +import cash.z.ecc.ui.screen.common.Header +import cash.z.ecc.ui.screen.common.NavigationButton +import cash.z.ecc.ui.screen.common.PrimaryButton +import cash.z.ecc.ui.screen.common.TertiaryButton +import cash.z.ecc.ui.screen.onboarding.model.Index +import cash.z.ecc.ui.theme.MINIMAL_WEIGHT +import cash.z.ecc.ui.theme.ZcashTheme + +@Preview(device = Devices.PIXEL_4) +@Composable +fun ComposablePreview() { + ZcashTheme(darkTheme = true) { + BackupWallet( + PersistableWalletFixture.new(), + BackupState(BackupStage.Test), + TestChoices(), + onComplete = {} + ) + } +} + +/** + * @param onComplete Callback when the user has completed the backup test. + */ +@Composable +fun BackupWallet( + wallet: PersistableWallet, + backupState: BackupState, + selectedTestChoices: TestChoices, + onComplete: () -> Unit, +) { + Surface { + Column { + when (backupState.current.collectAsState().value) { + BackupStage.EducationOverview -> EducationOverview(onNext = backupState::goNext) + BackupStage.EducationRecoveryPhrase -> EducationRecoveryPhrase(onNext = backupState::goNext) + BackupStage.Seed -> SeedPhrase( + wallet, onNext = backupState::goNext, + onCopyToClipboard = { + // TODO [#49] + } + ) + BackupStage.Test -> Test( + wallet, + selectedTestChoices, + onBack = backupState::goPrevious, + onNext = backupState::goNext + ) + BackupStage.Complete -> Complete( + onComplete = onComplete, + onBackToSeedPhrase = backupState::goToSeed + ) + } + } + } +} + +@Composable +private fun EducationOverview(onNext: () -> Unit) { + Column { + Header(stringResource(R.string.new_wallet_1_header)) + Body(stringResource(R.string.new_wallet_1_body_1)) + Spacer( + Modifier + .fillMaxWidth() + .weight(MINIMAL_WEIGHT, true) + ) + Body(stringResource(R.string.new_wallet_1_body_2)) + PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_1_button)) + } +} + +@Composable +private fun EducationRecoveryPhrase(onNext: () -> Unit) { + Column { + Header(stringResource(R.string.new_wallet_2_header)) + Body(stringResource(R.string.new_wallet_2_body_1)) + Spacer( + Modifier + .fillMaxWidth() + .weight(MINIMAL_WEIGHT, true) + ) + Body(stringResource(R.string.new_wallet_2_body_2)) + Card { + Body(stringResource(R.string.new_wallet_2_body_3)) + } + PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_2_button)) + } +} + +@Composable +private fun SeedPhrase(persistableWallet: PersistableWallet, onNext: () -> Unit, onCopyToClipboard: () -> Unit) { + Column { + Header(stringResource(R.string.new_wallet_3_header)) + Body(stringResource(R.string.new_wallet_3_body_1)) + + ChipGrid(persistableWallet) + + PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_3_button_finished)) + TertiaryButton(onClick = onCopyToClipboard, text = stringResource(R.string.new_wallet_3_button_copy)) + } +} + +@Suppress("MagicNumber") +private val testIndices = listOf(Index(4), Index(9), Index(16), Index(20)) + +private data class TestChoice(val originalIndex: Index, val word: String) + +@Composable +private fun Test( + wallet: PersistableWallet, + selectedTestChoices: TestChoices, + onBack: () -> Unit, + onNext: () -> Unit +) { + val splitSeedPhrase = wallet.seedPhrase.split + + val currentSelectedTestChoice = selectedTestChoices.current.collectAsState().value + + when { + currentSelectedTestChoice.size != testIndices.size -> { + TestInProgress(splitSeedPhrase, selectedTestChoices, onBack) + } + currentSelectedTestChoice.all { splitSeedPhrase[it.key.value] == it.value } -> { + // The user got the test correct + onNext() + } + currentSelectedTestChoice.none { null == it.value } -> { + TestFailure(onBack) + } + } +} + +/* + * A few implementation notes on the test: + * - It is possible for the same word to appear twice in the word choices + * - The test answer ordering is not randomized, to ensure it can never be in the correct order to start with + */ +@Composable +private fun TestInProgress( + splitSeedPhrase: List, + selectedTestChoices: TestChoices, + onBack: () -> Unit, +) { + val testChoices = splitSeedPhrase + .mapIndexed { index, word -> TestChoice(Index(index), word) } + .filter { testIndices.contains(it.originalIndex) } + .let { + // Don't randomize; otherwise there's a chance they'll be in the right order to start with. + @Suppress("MagicNumber") + listOf(it[1], it[0], it[3], it[2]) + } + val currentSelectedTestChoice = selectedTestChoices.current.collectAsState().value + Column { + // This button doesn't match the design; just providing the navigation hook for now + NavigationButton(onClick = onBack, text = stringResource(R.string.new_wallet_4_button_back)) + + Header(stringResource(R.string.new_wallet_4_header_verify)) + // Body(stringResource(R.string.new_wallet_4_body_verify)) + + Column { + splitSeedPhrase.chunked(CHIP_GRID_ROW_SIZE).forEachIndexed { chunkIndex, chunk -> + Row(Modifier.fillMaxWidth()) { + chunk.forEachIndexed { subIndex, word -> + val currentIndex = Index(chunkIndex * CHIP_GRID_ROW_SIZE + subIndex) + + if (testIndices.contains(currentIndex)) { + ChipDropDown( + currentIndex, + dropdownText = currentSelectedTestChoice[currentIndex] + ?: "", + choices = testChoices.map { it.word }, + modifier = Modifier + .weight(MINIMAL_WEIGHT) + .testTag(BackupTags.DROPDOWN_CHIP) + ) { + selectedTestChoices.set( + HashMap(currentSelectedTestChoice).apply { + this[currentIndex] = testChoices[it.value].word + } + ) + } + } else { + Chip( + index = Index(chunkIndex * CHIP_GRID_ROW_SIZE + subIndex), + text = word, + modifier = Modifier.weight(MINIMAL_WEIGHT) + ) + } + } + } + } + } + } +} + +@Composable +private fun TestFailure(onBackToSeedPhrase: () -> Unit) { + Column { + // This button doesn't match the design; just providing the navigation hook for now + NavigationButton(onClick = onBackToSeedPhrase, text = stringResource(R.string.new_wallet_4_button_back)) + + Header(stringResource(R.string.new_wallet_4_header_ouch)) + + Box(Modifier.fillMaxHeight(MINIMAL_WEIGHT)) + + Body(stringResource(R.string.new_wallet_4_body_ouch_retry)) + + PrimaryButton(onClick = onBackToSeedPhrase, text = stringResource(R.string.new_wallet_4_button_retry)) + } +} + +@Composable +private fun Complete(onComplete: () -> Unit, onBackToSeedPhrase: () -> Unit) { + Column { + Header(stringResource(R.string.new_wallet_5_header)) + Body(stringResource(R.string.new_wallet_5_body)) + Spacer( + Modifier + .fillMaxWidth() + .weight(MINIMAL_WEIGHT, true) + ) + PrimaryButton(onClick = onComplete, text = stringResource(R.string.new_wallet_5_button_finished)) + TertiaryButton(onClick = onBackToSeedPhrase, text = stringResource(R.string.new_wallet_5_button_back)) + } +} diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/view/DropDown.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/view/DropDown.kt new file mode 100644 index 00000000..d9461cde --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/view/DropDown.kt @@ -0,0 +1,89 @@ +package cash.z.ecc.ui.screen.backup.view + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import cash.z.ecc.ui.screen.backup.BackupTags +import cash.z.ecc.ui.screen.onboarding.model.Index +import cash.z.ecc.ui.theme.MINIMAL_WEIGHT +import cash.z.ecc.ui.theme.ZcashTheme + +/** + * @param chipIndex The index of the chip, which is displayed to the user. + * @param dropdownText Text to display when the drop down is not open. + * @param choices Item choices to display in the open drop down menu. Positional index is important. + * @param onChoiceSelected Callback with the positional index of the item the user selected from [choices]. + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun ChipDropDown( + chipIndex: Index, + dropdownText: String, + choices: List, + modifier: Modifier = Modifier, + onChoiceSelected: (Index) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + Surface( + modifier = modifier.then(Modifier.padding(4.dp)), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colors.secondary, + elevation = 8.dp, + onClick = { expanded = !expanded } + ) { + Row(modifier = Modifier.padding(8.dp)) { + Text( + text = (chipIndex.value + 1).toString(), + style = ZcashTheme.typography.chipIndex, + color = ZcashTheme.colors.chipIndex, + ) + Spacer(modifier = Modifier.padding(horizontal = 2.dp, vertical = 0.dp)) + Text(dropdownText) + Spacer(modifier = modifier.fillMaxWidth(MINIMAL_WEIGHT)) + Icon( + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + val dropdownModifier = if (expanded) { + Modifier.testTag(BackupTags.DROPDOWN_MENU) + } else { + Modifier + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = dropdownModifier + ) { + choices.forEachIndexed { index, label -> + DropdownMenuItem(onClick = { + expanded = false + onChoiceSelected(Index(index)) + }) { + Text(text = label) + } + } + } + } +} diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/viewmodel/BackupViewModel.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/viewmodel/BackupViewModel.kt new file mode 100644 index 00000000..28a8cabd --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/viewmodel/BackupViewModel.kt @@ -0,0 +1,59 @@ +package cash.z.ecc.ui.screen.backup.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import cash.z.ecc.android.sdk.ext.collectWith +import cash.z.ecc.ui.screen.backup.model.BackupStage +import cash.z.ecc.ui.screen.backup.state.BackupState +import cash.z.ecc.ui.screen.backup.state.TestChoices +import cash.z.ecc.ui.screen.onboarding.model.Index + +class BackupViewModel(application: Application, savedStateHandle: SavedStateHandle) : AndroidViewModel(application) { + val backupState: BackupState = run { + val initialValue = if (savedStateHandle.contains(KEY_STAGE)) { + savedStateHandle.get(KEY_STAGE) + } else { + null + } + + if (null == initialValue) { + BackupState() + } else { + BackupState(initialValue) + } + } + + val testChoices: TestChoices = run { + val initialValue = if (savedStateHandle.contains(KEY_TEST_CHOICES)) { + savedStateHandle.get>(KEY_TEST_CHOICES) + } else { + null + } + + if (null == initialValue) { + TestChoices() + } else { + TestChoices(initialValue) + } + } + + init { + // viewModelScope is constructed with Dispatchers.Main.immediate, so this will + // update the save state as soon as a change occurs. + backupState.current.collectWith(viewModelScope) { + savedStateHandle.set(KEY_STAGE, it) + } + + testChoices.current.collectWith(viewModelScope) { + // copy as explicit HashMap, since HashMap can be stored in a Bundle + savedStateHandle.set(KEY_TEST_CHOICES, HashMap(it)) + } + } + + companion object { + private const val KEY_STAGE = "stage" // $NON-NLS + private const val KEY_TEST_CHOICES = "test_choices" // $NON-NLS + } +} diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/common/Chip.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/common/Chip.kt index 11c44f9e..302868d4 100644 --- a/ui-lib/src/main/java/cash/z/ecc/ui/screen/common/Chip.kt +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/common/Chip.kt @@ -17,7 +17,7 @@ import cash.z.ecc.ui.theme.ZcashTheme @Composable fun ComposablePreview() { ZcashTheme(darkTheme = false) { - Chip(Index(1), "edict") + Chip(Index(0), "edict") } } @@ -25,16 +25,17 @@ fun ComposablePreview() { fun Chip( index: Index, text: String, + modifier: Modifier = Modifier, ) { Surface( - modifier = Modifier.padding(4.dp), + modifier = modifier.then(Modifier.padding(4.dp)), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colors.secondary, elevation = 8.dp, ) { Row(modifier = Modifier.padding(8.dp)) { Text( - text = index.value.toString(), + text = (index.value + 1).toString(), style = ZcashTheme.typography.chipIndex, color = ZcashTheme.colors.chipIndex, ) diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/common/ChipGrid.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/common/ChipGrid.kt new file mode 100644 index 00000000..abbcd495 --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/common/ChipGrid.kt @@ -0,0 +1,29 @@ +package cash.z.ecc.ui.screen.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import cash.z.ecc.sdk.model.PersistableWallet +import cash.z.ecc.ui.screen.onboarding.model.Index +import cash.z.ecc.ui.theme.MINIMAL_WEIGHT + +const val CHIP_GRID_ROW_SIZE = 3 + +@Composable +fun ChipGrid(persistableWallet: PersistableWallet) { + Column { + persistableWallet.seedPhrase.split.chunked(CHIP_GRID_ROW_SIZE).forEachIndexed { chunkIndex, chunk -> + Row(Modifier.fillMaxWidth()) { + chunk.forEachIndexed { subIndex, word -> + Chip( + index = Index(chunkIndex * CHIP_GRID_ROW_SIZE + subIndex), + text = word, + modifier = Modifier.weight(MINIMAL_WEIGHT) + ) + } + } + } + } +} diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/view/HomeView.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/view/HomeView.kt index 65ffa268..13457761 100644 --- a/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/view/HomeView.kt +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/view/HomeView.kt @@ -5,18 +5,20 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import cash.z.ecc.sdk.fixture.PersistableWalletFixture +import cash.z.ecc.sdk.model.PersistableWallet import cash.z.ecc.ui.theme.ZcashTheme @Preview @Composable fun ComposablePreview() { ZcashTheme(darkTheme = true) { - Home() + Home(PersistableWalletFixture.new()) } } @Composable -fun Home() { +fun Home(@Suppress("UNUSED_PARAMETER") persistableWallet: PersistableWallet) { Surface { Column { // Placeholder diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/viewmodel/WalletViewModel.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/viewmodel/WalletViewModel.kt index c9da74d7..dd8e0c54 100644 --- a/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/viewmodel/WalletViewModel.kt +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/viewmodel/WalletViewModel.kt @@ -3,72 +3,118 @@ package cash.z.ecc.ui.screen.home.viewmodel import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import cash.z.ecc.android.sdk.Synchronizer import cash.z.ecc.sdk.SynchronizerCompanion import cash.z.ecc.sdk.model.PersistableWallet import cash.z.ecc.ui.common.ANDROID_STATE_FLOW_TIMEOUT_MILLIS import cash.z.ecc.ui.preference.EncryptedPreferenceKeys import cash.z.ecc.ui.preference.EncryptedPreferenceSingleton +import cash.z.ecc.ui.preference.StandardPreferenceKeys +import cash.z.ecc.ui.preference.StandardPreferenceSingleton import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock // To make this more multiplatform compatible, we need to remove the dependency on Context // for loading the preferences. class WalletViewModel(application: Application) : AndroidViewModel(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() + /** * A flow of the user's stored wallet. Null indicates that no wallet has been stored. */ - /* - * This is exposed, because loading the value here is faster than loading the entire Zcash SDK. - * - * This allows the UI to load the first launch onboarding experience a few hundred milliseconds - * faster. - */ - val persistableWallet = flow { + private 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(application) emitAll(EncryptedPreferenceKeys.PERSISTABLE_WALLET.observe(encryptedPreferenceProvider)) - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS), - null - ) + } /** - * A flow of the Zcash SDK initialized with the user's stored wallet. Null indicates that no - * wallet has been stored. + * A flow of whether a backup of the user's wallet has been performed. */ - // Note: in the future we might want to convert this to emitting a sealed class with states like: - // - No wallet - // - Current wallet - // - Error loading wallet - val synchronizer = persistableWallet.map { persistableWallet -> - persistableWallet?.let { SynchronizerCompanion.load(application, persistableWallet) } - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS), - null - ) + private val isBackupCompleteFlow = flow { + val preferenceProvider = StandardPreferenceSingleton.getInstance(application) + emitAll(StandardPreferenceKeys.IS_USER_BACKUP_COMPLETE.observe(preferenceProvider)) + } + + val state: StateFlow = persistableWalletFlow + .combine(isBackupCompleteFlow) { persistableWallet: PersistableWallet?, isBackupComplete: Boolean -> + if (null == persistableWallet) { + WalletState.NoWallet + } else if (!isBackupComplete) { + WalletState.NeedsBackup(persistableWallet) + } else { + WalletState.Ready(persistableWallet, SynchronizerCompanion.load(application, persistableWallet)) + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS), + WalletState.Loading + ) /** * Persists a wallet asynchronously. Clients observe either [persistableWallet] or [synchronizer] - * to see the side effects. - * - * This method does not prevent multiple calls, so clients should be careful not to call this - * method multiple times in rapid succession. While the persistableWallet write is atomic, - * the ordering of the writes is not specified. If the same persistableWallet is passed in, - * then there's no problem. But if different persistableWallets are passed in, then which one - * actually gets written is non-deterministic. + * to see the side effects. This would be used for a user restoring a wallet from a backup. */ fun persistWallet(persistableWallet: PersistableWallet) { viewModelScope.launch { val preferenceProvider = EncryptedPreferenceSingleton.getInstance(getApplication()) - EncryptedPreferenceKeys.PERSISTABLE_WALLET.putValue(preferenceProvider, persistableWallet) + persistWalletMutex.withLock { + EncryptedPreferenceKeys.PERSISTABLE_WALLET.putValue(preferenceProvider, persistableWallet) + } + } + } + + /** + * Creates a wallet asynchronously and then persists it. Clients observe + * [state] 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 createAndPersistWallet() { + val application = getApplication() + + viewModelScope.launch { + val newWallet = PersistableWallet.new(application) + persistWallet(newWallet) + } + } + + /** + * Asynchronously notes that the user has completed the backup steps, which means the wallet + * is ready to use. Clients observe [state] to see the side effects. This would be used + * for a user creating a new wallet. + */ + fun persistBackupComplete() { + val application = getApplication() + + viewModelScope.launch { + val preferenceProvider = StandardPreferenceSingleton.getInstance(application) + StandardPreferenceKeys.IS_USER_BACKUP_COMPLETE.putValue(preferenceProvider, true) } } } + +/** + * Represents the state of the wallet + */ +sealed class WalletState { + object Loading : WalletState() + object NoWallet : WalletState() + class NeedsBackup(val persistableWallet: PersistableWallet) : WalletState() + class Ready(val persistableWallet: PersistableWallet, val synchronizer: Synchronizer) : WalletState() +} diff --git a/ui-lib/src/main/res/ui/new_wallet/values/strings.xml b/ui-lib/src/main/res/ui/new_wallet/values/strings.xml new file mode 100644 index 00000000..24be7bb7 --- /dev/null +++ b/ui-lib/src/main/res/ui/new_wallet/values/strings.xml @@ -0,0 +1,30 @@ + + First things first + It is important to understand that you are in charge here. Great, right? YOU get to be the bank! + But it also means that YOU are the customer, and you need to be self-reliant.\n\nSo how do you recover funds that you‘ve hidden on a complete decentralized and private block-chain? + By understanding and preparing + + A Recovery Phrase + A recovery phrase is a series of 24 words in a specific order. This word combination is unique to your wallet and creates all of your different addresses. + You can restore your wallet with these words when needed. + So can anyone else, so keep them to yourself. It‘s a SECRET recovery phrase. If someone asks you for it, they are probably up to no good. + Got it, let‘s back them up + + Your Secret Recovery Phrase + These words represent your funds and the security used to protect them.\n\nBack them up now! There will be a test. + Finished! + Copy to buffer + + Verify Your Backup + Ouch, sorry, no. + Drag the words below to match your backed-up copy. + Your placed words did not match your secret recovery phrase. + Your placed words did not match your secret recovery phrase.\n\nRemember, you can‘t recover your funds if you lose (or incorrectly save) these 24 words. + I‘m ready to try again + Back + + Success! + Place that backup somewhere safe and venture forth in security. + Take me to my wallet! + Show me my phrase again +