From 2e40af9390836a54df0c635850d9e80efc3063be Mon Sep 17 00:00:00 2001 From: Carter Jernigan Date: Thu, 9 Dec 2021 15:21:30 -0500 Subject: [PATCH 1/2] [#17] Import wallet --- docs/Setup.md | 4 +- gradle.properties | 2 +- preference-impl-android-lib/build.gradle.kts | 18 +- .../z/ecc/sdk/model/PersistableWalletTest.kt | 5 +- .../cash/z/ecc/sdk/model/SeedPhraseTest.kt | 26 ++ .../cash/z/ecc/sdk/SynchronizerCompanion.kt | 4 +- .../sdk/fixture/PersistableWalletFixture.kt | 7 +- .../z/ecc/sdk/fixture/SeedPhraseFixture.kt | 10 + .../cash/z/ecc/sdk/model/PersistableWallet.kt | 18 +- .../java/cash/z/ecc/sdk/model/SeedPhrase.kt | 17 +- .../z/ecc/sdk/model/SeedPhraseValidation.kt | 37 ++ ui-lib/build.gradle.kts | 3 +- .../java/cash/z/ecc/ui/common/ListExtTest.kt | 36 ++ .../ui/screen/backup/view/BackupViewTest.kt | 32 +- .../screen/restore/model/ParseResultTest.kt | 102 +++++ .../ui/screen/restore/model/WordListTest.kt | 30 ++ .../ui/screen/restore/view/RestoreViewTest.kt | 234 ++++++++++++ ui-lib/src/main/AndroidManifest.xml | 7 +- .../main/java/cash/z/ecc/ui/MainActivity.kt | 75 +++- .../main/java/cash/z/ecc/ui/common/ListExt.kt | 3 + .../backup/{BackupTags.kt => BackupTag.kt} | 2 +- .../z/ecc/ui/screen/backup/view/BackupView.kt | 6 +- .../z/ecc/ui/screen/backup/view/DropDown.kt | 4 +- .../java/cash/z/ecc/ui/screen/common/Chip.kt | 7 +- .../cash/z/ecc/ui/screen/common/ChipGrid.kt | 19 +- .../cash/z/ecc/ui/screen/common/CommonTag.kt | 6 + .../screen/home/viewmodel/WalletViewModel.kt | 41 +- .../viewmodel/OnboardingViewModel.kt | 15 + .../z/ecc/ui/screen/restore/RestoreTag.kt | 11 + .../ui/screen/restore/model/ParseResult.kt | 63 ++++ .../z/ecc/ui/screen/restore/state/WordList.kt | 26 ++ .../ecc/ui/screen/restore/view/RestoreView.kt | 354 ++++++++++++++++++ .../restore/viewmodel/RestoreViewModel.kt | 68 ++++ .../main/java/cash/z/ecc/ui/theme/Color.kt | 6 + .../main/java/cash/z/ecc/ui/theme/Theme.kt | 18 +- .../main/res/ui/restore/values/strings.xml | 15 + 36 files changed, 1238 insertions(+), 93 deletions(-) create mode 100644 sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/model/SeedPhraseTest.kt create mode 100644 sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/SeedPhraseFixture.kt create mode 100644 sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/SeedPhraseValidation.kt create mode 100644 ui-lib/src/androidTest/java/cash/z/ecc/ui/common/ListExtTest.kt create mode 100644 ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/restore/model/ParseResultTest.kt create mode 100644 ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/restore/model/WordListTest.kt create mode 100644 ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/restore/view/RestoreViewTest.kt create mode 100644 ui-lib/src/main/java/cash/z/ecc/ui/common/ListExt.kt rename ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/{BackupTags.kt => BackupTag.kt} (90%) create mode 100644 ui-lib/src/main/java/cash/z/ecc/ui/screen/common/CommonTag.kt create mode 100644 ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/RestoreTag.kt create mode 100644 ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/model/ParseResult.kt create mode 100644 ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/state/WordList.kt create mode 100644 ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/view/RestoreView.kt create mode 100644 ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/viewmodel/RestoreViewModel.kt create mode 100644 ui-lib/src/main/res/ui/restore/values/strings.xml diff --git a/docs/Setup.md b/docs/Setup.md index 5290dd34..905dbd4a 100644 --- a/docs/Setup.md +++ b/docs/Setup.md @@ -11,7 +11,7 @@ To get set up for development, there are several steps that you need to go throu Start by making sure the command line with Gradle works first, because **all the Android Studio run configurations use Gradle internally.** The run configurations are not magic—they map directly to command line invocations with different arguments. 1. Install Java - 1. Install JVM 11 or greater on your system. Our setup has been tested with Java 11-17. For Windows or Linux, be sure that the `JAVA_HOME` environment variable points to the right Java version. + 1. Install JVM 11 or greater on your system. Our setup has been tested with Java 11-17. For Windows or Linux, be sure that the `JAVA_HOME` environment variable points to the right Java version. Note: If you switch from a newer to an older JVM version, you may see an error like the following `> com.android.ide.common.signing.KeytoolException: Failed to read key AndroidDebugKey from store "~/.android/debug.keystore": Integrity check failed: java.security.NoSuchAlgorithmException: Algorithm HmacPBESHA256 not available`. A solution is to delete the debug keystore and allow it to be re-generated. 1. Android Studio has an embedded JVM, although running Gradle tasks from the command line requires a separate JVM to be installed. Our Gradle scripts are configured to use toolchains to automatically install the correct JVM version. _Note: The ktlintFormat task will fail on Apple Silicon unless a Java 11 virtual machine is installed manually._ 1. Install Android Studio and the Android SDK 1. Download the [Android Studio Bumblebee Beta](https://developer.android.com/studio/preview) (we're using the Beta version, due to its improved integration with Jetpack Compose) @@ -34,6 +34,8 @@ Start by making sure the command line with Gradle works first, because **all the 1. After Android Studio finishes syncing with Gradle, look for the green "play" run button in the toolbar. To the left of it, choose the "app" run configuration under the dropdown menu. Then hit the run button ## Troubleshooting +1. Verify that the Git repo has not been modified. Due to strict dependency locking (for security reasons), the build will fail unless the locks are also updated +1. If you 1. Try running from the command line instead of Android Studio, to rule out Android Studio issues. If it works from the command line, try this step to reset Android Studio 1. Quit Android Studio 2. Deleting the invisible `.idea` in the root directory of the project diff --git a/gradle.properties b/gradle.properties index e26d8f4e..ced630cb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,7 +23,7 @@ IS_COVERAGE_ENABLED=false # Optionally configure test orchestrator. # It is disabled by default, because it causes tests to take about 2x longer to run. -IS_USE_TEST_ORCHESTRATOR=true +IS_USE_TEST_ORCHESTRATOR=false # Optionally disable minification IS_MINIFY_ENABLED=true diff --git a/preference-impl-android-lib/build.gradle.kts b/preference-impl-android-lib/build.gradle.kts index 969920c7..ede6154b 100644 --- a/preference-impl-android-lib/build.gradle.kts +++ b/preference-impl-android-lib/build.gradle.kts @@ -12,6 +12,16 @@ android { allWarningsAsErrors = project.property("IS_TREAT_WARNINGS_AS_ERRORS").toString().toBoolean() freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.RequiresOptIn" } + + // Force orchestrator to be used for this module, because we need the preference files + // to be purged between tests + defaultConfig { + testInstrumentationRunnerArguments["clearPackageData"] = "true" + } + + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } } dependencies { @@ -24,11 +34,9 @@ dependencies { androidTestImplementation(libs.bundles.androidx.test) androidTestImplementation(libs.kotlinx.coroutines.test) - if (project.property("IS_USE_TEST_ORCHESTRATOR").toString().toBoolean()) { - androidTestUtil(libs.androidx.test.orchestrator) { - artifact { - type = "apk" - } + androidTestUtil(libs.androidx.test.orchestrator) { + artifact { + type = "apk" } } } diff --git a/sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/model/PersistableWalletTest.kt b/sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/model/PersistableWalletTest.kt index c4b0b9fd..46be0125 100644 --- a/sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/model/PersistableWalletTest.kt +++ b/sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/model/PersistableWalletTest.kt @@ -3,6 +3,7 @@ package cash.z.ecc.sdk.model import androidx.test.filters.SmallTest import cash.z.ecc.android.sdk.type.ZcashNetwork import cash.z.ecc.sdk.fixture.PersistableWalletFixture +import cash.z.ecc.sdk.fixture.SeedPhraseFixture import cash.z.ecc.sdk.test.count import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -24,7 +25,7 @@ class PersistableWalletTest { assertEquals(1, jsonObject.getInt(PersistableWallet.KEY_VERSION)) assertEquals(ZcashNetwork.Testnet.id, jsonObject.getInt(PersistableWallet.KEY_NETWORK_ID)) - assertEquals(PersistableWalletFixture.SEED_PHRASE, jsonObject.getString(PersistableWallet.KEY_SEED_PHRASE)) + assertEquals(PersistableWalletFixture.SEED_PHRASE.joinToString(), jsonObject.getString(PersistableWallet.KEY_SEED_PHRASE)) // Birthday serialization is tested in a separate file } @@ -45,6 +46,6 @@ class PersistableWalletTest { fun toString_security() { val actual = PersistableWalletFixture.new().toString() - assertFalse(actual.contains(PersistableWalletFixture.SEED_PHRASE)) + assertFalse(actual.contains(SeedPhraseFixture.SEED_PHRASE)) } } diff --git a/sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/model/SeedPhraseTest.kt b/sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/model/SeedPhraseTest.kt new file mode 100644 index 00000000..392a558c --- /dev/null +++ b/sdk-ext-lib/src/androidTest/java/cash/z/ecc/sdk/model/SeedPhraseTest.kt @@ -0,0 +1,26 @@ +package cash.z.ecc.sdk.model + +import androidx.test.filters.SmallTest +import cash.z.ecc.sdk.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/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 148c057a..01264747 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 @@ -25,7 +25,7 @@ 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.phrase).toSeed() + Mnemonics.MnemonicCode(seedPhrase.joinToString()).toSeed() } // Dispatchers needed until an SDK is published with the implementation of @@ -42,6 +42,6 @@ private suspend fun PersistableWallet.toConfig(): Initializer.Config { val vk = deriveViewingKey() return Initializer.Config { - it.importWallet(vk, birthday.height, network, network.defaultHost, network.defaultPort) + it.importWallet(vk, birthday?.height, network, network.defaultHost, network.defaultPort) } } diff --git a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/PersistableWalletFixture.kt b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/PersistableWalletFixture.kt index 9accd6b4..954099de 100644 --- a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/PersistableWalletFixture.kt +++ b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/PersistableWalletFixture.kt @@ -11,12 +11,11 @@ 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" + val SEED_PHRASE = SeedPhraseFixture.new() fun new( network: ZcashNetwork = NETWORK, birthday: WalletBirthday = BIRTHDAY, - seedPhrase: String = SEED_PHRASE - ) = PersistableWallet(network, birthday, SeedPhrase(seedPhrase)) + seedPhrase: SeedPhrase = SEED_PHRASE + ) = PersistableWallet(network, birthday, seedPhrase) } diff --git a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/SeedPhraseFixture.kt b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/SeedPhraseFixture.kt new file mode 100644 index 00000000..4f7e850a --- /dev/null +++ b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/SeedPhraseFixture.kt @@ -0,0 +1,10 @@ +package cash.z.ecc.sdk.fixture + +import cash.z.ecc.sdk.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/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 1da2fa1b..c216df44 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 @@ -16,7 +16,7 @@ import org.json.JSONObject */ data class PersistableWallet( val network: ZcashNetwork, - val birthday: WalletBirthday, + val birthday: WalletBirthday?, val seedPhrase: SeedPhrase ) { @@ -28,8 +28,10 @@ data class PersistableWallet( fun toJson() = JSONObject().apply { put(KEY_VERSION, VERSION_1) put(KEY_NETWORK_ID, network.id) - put(KEY_BIRTHDAY, birthday.toJson()) - put(KEY_SEED_PHRASE, seedPhrase.phrase) + birthday?.let { + put(KEY_BIRTHDAY, it.toJson()) + } + put(KEY_SEED_PHRASE, seedPhrase.joinToString()) } override fun toString(): String { @@ -49,10 +51,14 @@ data class PersistableWallet( when (val version = jsonObject.getInt(KEY_VERSION)) { VERSION_1 -> { val networkId = jsonObject.getInt(KEY_NETWORK_ID) - val birthday = WalletBirthdayCompanion.from(jsonObject.getJSONObject(KEY_BIRTHDAY)) + val birthday = if (jsonObject.has(KEY_BIRTHDAY)) { + WalletBirthdayCompanion.from(jsonObject.getJSONObject(KEY_BIRTHDAY)) + } else { + null + } val seedPhrase = jsonObject.getString(KEY_SEED_PHRASE) - return PersistableWallet(ZcashNetwork.from(networkId), birthday, SeedPhrase(seedPhrase)) + return PersistableWallet(ZcashNetwork.from(networkId), birthday, SeedPhrase.new(seedPhrase)) } else -> { throw IllegalArgumentException("Unsupported version $version") @@ -82,4 +88,4 @@ 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() }) +private suspend fun newSeedPhrase() = SeedPhrase(newMnemonic().map { 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 index 22c00029..0f38b4c9 100644 --- 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 @@ -1,20 +1,23 @@ package cash.z.ecc.sdk.model -data class SeedPhrase(val phrase: String) { - val split = phrase.split(" ") - +// 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}" } } - override fun toString(): String { - // For security, intentionally override the toString method to reduce risk of accidentally logging secrets - return "SeedPhrase" - } + // For security, intentionally override the toString method to reduce risk of accidentally logging secrets + override fun toString() = "SeedPhrase" + + fun joinToString() = split.joinToString(DEFAULT_DELIMITER) companion object { const val SEED_PHRASE_SIZE = 24 + + const val DEFAULT_DELIMITER = " " + + fun new(phrase: String) = SeedPhrase(phrase.split(DEFAULT_DELIMITER)) } } diff --git a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/SeedPhraseValidation.kt b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/SeedPhraseValidation.kt new file mode 100644 index 00000000..8e412710 --- /dev/null +++ b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/SeedPhraseValidation.kt @@ -0,0 +1,37 @@ +package cash.z.ecc.sdk.model + +import cash.z.ecc.android.bip39.Mnemonics +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.Locale + +// This is a stopgap; would like to see improvements to the SeedPhrase class to have validation moved +// there as part of creating the object +sealed class SeedPhraseValidation { + object BadCount : SeedPhraseValidation() + object BadWord : SeedPhraseValidation() + object FailedChecksum : SeedPhraseValidation() + class Valid(val seedPhrase: SeedPhrase) : SeedPhraseValidation() + + companion object { + suspend fun new(list: List): SeedPhraseValidation { + if (list.size != SeedPhrase.SEED_PHRASE_SIZE) { + return BadCount + } + + @Suppress("SwallowedException") + return try { + val stringified = list.joinToString(SeedPhrase.DEFAULT_DELIMITER) + withContext(Dispatchers.Main) { + Mnemonics.MnemonicCode(stringified, Locale.ENGLISH.language).validate() + } + + Valid(SeedPhrase.new(stringified)) + } catch (e: Mnemonics.InvalidWordException) { + BadWord + } catch (e: Mnemonics.ChecksumException) { + FailedChecksum + } + } + } +} diff --git a/ui-lib/build.gradle.kts b/ui-lib/build.gradle.kts index 7f129433..57e4e843 100644 --- a/ui-lib/build.gradle.kts +++ b/ui-lib/build.gradle.kts @@ -29,7 +29,8 @@ android { setOf( "src/main/res/ui/common", "src/main/res/ui/onboarding", - "src/main/res/ui/backup" + "src/main/res/ui/backup", + "src/main/res/ui/restore" ) ) } diff --git a/ui-lib/src/androidTest/java/cash/z/ecc/ui/common/ListExtTest.kt b/ui-lib/src/androidTest/java/cash/z/ecc/ui/common/ListExtTest.kt new file mode 100644 index 00000000..911b6277 --- /dev/null +++ b/ui-lib/src/androidTest/java/cash/z/ecc/ui/common/ListExtTest.kt @@ -0,0 +1,36 @@ +package cash.z.ecc.ui.common + +import androidx.test.filters.SmallTest +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.contains +import org.junit.Test + +class ListExtTest { + @Test + @SmallTest + fun first_under() { + val limited = listOf(1, 2, 3).first(2) + + assertThat(limited.count(), equalTo(2)) + assertThat(limited, contains(1, 2)) + } + + @Test + @SmallTest + fun first_equal() { + val limited = listOf(1, 2, 3).first(3) + + assertThat(limited.count(), equalTo(3)) + assertThat(limited, contains(1, 2, 3)) + } + + @Test + @SmallTest + fun first_over() { + val limited = listOf(1, 2, 3).first(5) + + assertThat(limited.count(), equalTo(3)) + assertThat(limited, contains(1, 2, 3)) + } +} 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 index 4e70d457..fad1b82d 100644 --- 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 @@ -12,7 +12,7 @@ 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.BackupTag 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 @@ -64,20 +64,20 @@ class BackupViewTest { fun test_pass() { val testSetup = newTestSetup(BackupStage.Test) - composeTestRule.onAllNodesWithTag(BackupTags.DROPDOWN_CHIP).also { + composeTestRule.onAllNodesWithTag(BackupTag.DROPDOWN_CHIP).also { it.assertCountEquals(4) it[0].performClick() - composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[1].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[1].performClick() it[1].performClick() - composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[0].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[0].performClick() it[2].performClick() - composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[3].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[3].performClick() it[3].performClick() - composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[2].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[2].performClick() } assertEquals(BackupStage.Complete, testSetup.getStage()) @@ -88,20 +88,20 @@ class BackupViewTest { fun test_fail() { val testSetup = newTestSetup(BackupStage.Test) - composeTestRule.onAllNodesWithTag(BackupTags.DROPDOWN_CHIP).also { + composeTestRule.onAllNodesWithTag(BackupTag.DROPDOWN_CHIP).also { it.assertCountEquals(4) it[0].performClick() - composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[0].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[0].performClick() it[1].performClick() - composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[1].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[1].performClick() it[2].performClick() - composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[2].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[2].performClick() it[3].performClick() - composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[3].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[3].performClick() } assertEquals(BackupStage.Test, testSetup.getStage()) @@ -181,20 +181,20 @@ class BackupViewTest { assertEquals(BackupStage.Test, testSetup.getStage()) - composeTestRule.onAllNodesWithTag(BackupTags.DROPDOWN_CHIP).also { + composeTestRule.onAllNodesWithTag(BackupTag.DROPDOWN_CHIP).also { it.assertCountEquals(4) it[0].performClick() - composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[1].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[1].performClick() it[1].performClick() - composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[0].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[0].performClick() it[2].performClick() - composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[3].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[3].performClick() it[3].performClick() - composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[2].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[2].performClick() } assertEquals(BackupStage.Complete, testSetup.getStage()) diff --git a/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/restore/model/ParseResultTest.kt b/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/restore/model/ParseResultTest.kt new file mode 100644 index 00000000..d4c6dc1f --- /dev/null +++ b/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/restore/model/ParseResultTest.kt @@ -0,0 +1,102 @@ +package cash.z.ecc.ui.screen.restore.model + +import androidx.test.filters.SmallTest +import cash.z.ecc.sdk.model.SeedPhrase +import org.hamcrest.Matchers.not +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ParseResultTest { + companion object { + private val SAMPLE_WORD_LIST = setOf("bar", "baz", "foo") + } + + @Test + @SmallTest + fun continue_empty() { + val actual = ParseResult.new(SAMPLE_WORD_LIST, "") + assertEquals(ParseResult.Continue, actual) + } + + @Test + @SmallTest + fun continue_blank() { + val actual = ParseResult.new(SAMPLE_WORD_LIST, " ") + assertEquals(ParseResult.Continue, actual) + } + + @Test + @SmallTest + fun add_single() { + val actual = ParseResult.new(SAMPLE_WORD_LIST, "baz") + assertEquals(ParseResult.Add(listOf("baz")), actual) + } + + @Test + @SmallTest + fun add_single_trimmed() { + val actual = ParseResult.new(SAMPLE_WORD_LIST, "foo ") + assertEquals(ParseResult.Add(listOf("foo")), actual) + } + + @Test + @SmallTest + fun add_multiple() { + val actual = ParseResult.new(SAMPLE_WORD_LIST, SAMPLE_WORD_LIST.joinToString(SeedPhrase.DEFAULT_DELIMITER)) + assertEquals(ParseResult.Add(listOf("bar", "baz", "foo")), actual) + } + + @Test + @SmallTest + fun add_security() { + val actual = ParseResult.new(SAMPLE_WORD_LIST, "foo") + assertTrue(actual is ParseResult.Add) + assertFalse(actual.toString().contains("foo")) + } + + @Test + @SmallTest + fun autocomplete_single() { + val actual = ParseResult.new(SAMPLE_WORD_LIST, "f") + assertEquals(ParseResult.Autocomplete(listOf("foo")), actual) + } + + @Test + @SmallTest + fun autocomplete_multiple() { + val actual = ParseResult.new(SAMPLE_WORD_LIST, "ba") + assertEquals(ParseResult.Autocomplete(listOf("bar", "baz")), actual) + } + + @Test + @SmallTest + fun autocomplete_security() { + val actual = ParseResult.new(SAMPLE_WORD_LIST, "f") + assertTrue(actual is ParseResult.Autocomplete) + assertFalse(actual.toString().contains("foo")) + } + + @Test + @SmallTest + fun warn_backwards_recursion() { + val actual = ParseResult.new(SAMPLE_WORD_LIST, "bb") + assertEquals(ParseResult.Warn(listOf("bar", "baz")), actual) + } + + @Test + @SmallTest + fun warn_backwards_recursion_2() { + val actual = ParseResult.new(SAMPLE_WORD_LIST, "bad") + assertEquals(ParseResult.Warn(listOf("bar", "baz")), actual) + } + + @Test + @SmallTest + fun warn_security() { + val actual = ParseResult.new(SAMPLE_WORD_LIST, "foob") + assertTrue(actual is ParseResult.Warn) + assertFalse(actual.toString().contains("foo")) + } +} diff --git a/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/restore/model/WordListTest.kt b/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/restore/model/WordListTest.kt new file mode 100644 index 00000000..9c4ca5d9 --- /dev/null +++ b/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/restore/model/WordListTest.kt @@ -0,0 +1,30 @@ +package cash.z.ecc.ui.screen.restore.model + +import cash.z.ecc.ui.screen.restore.state.WordList +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class WordListTest { + @Test + fun append() { + val wordList = WordList(listOf("foo")) + val initialList = wordList.current.value + + wordList.append(listOf("bar")) + + assertEquals(listOf("foo", "bar"), wordList.current.value) + assertNotEquals(initialList, wordList.current.value) + } + + @Test + fun set() { + val wordList = WordList(listOf("foo")) + val initialList = wordList.current.value + + wordList.set(listOf("bar")) + + assertEquals(listOf("bar"), wordList.current.value) + assertNotEquals(initialList, wordList.current.value) + } +} diff --git a/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/restore/view/RestoreViewTest.kt b/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/restore/view/RestoreViewTest.kt new file mode 100644 index 00000000..194a8ddb --- /dev/null +++ b/ui-lib/src/androidTest/java/cash/z/ecc/ui/screen/restore/view/RestoreViewTest.kt @@ -0,0 +1,234 @@ +package cash.z.ecc.ui.screen.restore.view + +import android.content.Context +import android.view.inputmethod.InputMethodManager +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.assertTextEquals +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.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import androidx.test.filters.MediumTest +import cash.z.ecc.android.bip39.Mnemonics +import cash.z.ecc.sdk.fixture.SeedPhraseFixture +import cash.z.ecc.sdk.model.SeedPhrase +import cash.z.ecc.ui.R +import cash.z.ecc.ui.screen.common.CommonTag +import cash.z.ecc.ui.screen.restore.RestoreTag +import cash.z.ecc.ui.screen.restore.state.WordList +import cash.z.ecc.ui.test.getStringResource +import cash.z.ecc.ui.theme.ZcashTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import java.util.Locale + +class RestoreViewTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + @MediumTest + fun keyboard_appears_on_launch() { + newTestSetup(emptyList()) + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also { + it.assertIsFocused() + } + + val inputMethodManager = ApplicationProvider.getApplicationContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + assertTrue(inputMethodManager.isAcceptingText) + } + + @Test + @MediumTest + fun autocomplete_suggestions_appear() { + newTestSetup(emptyList()) + + composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also { + it.performTextInput("ab") + + // Make sure text isn't cleared + it.assertTextContains("ab") + } + + composeTestRule.onNode(hasText("abandon") and hasTestTag(RestoreTag.AUTOCOMPLETE_ITEM)).also { + it.assertExists() + } + + composeTestRule.onNode(hasText("able") and hasTestTag(RestoreTag.AUTOCOMPLETE_ITEM)).also { + it.assertExists() + } + } + + @Test + @MediumTest + fun choose_autocomplete() { + newTestSetup(emptyList()) + + composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also { + it.performTextInput("ab") + } + + composeTestRule.onNode(hasText("abandon") and hasTestTag(RestoreTag.AUTOCOMPLETE_ITEM)).also { + it.performClick() + } + + composeTestRule.onNodeWithTag(RestoreTag.AUTOCOMPLETE_LAYOUT).also { + it.assertDoesNotExist() + } + + composeTestRule.onNode(hasText("abandon") and hasTestTag(CommonTag.CHIP), useUnmergedTree = true).also { + it.assertExists() + } + + composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also { + it.assertTextEquals("") + } + } + + @Test + @MediumTest + fun type_full_word() { + newTestSetup(emptyList()) + + composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also { + it.performTextInput("abandon") + } + + composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also { + it.assertTextEquals("") + } + + composeTestRule.onNodeWithTag(RestoreTag.AUTOCOMPLETE_LAYOUT).also { + it.assertDoesNotExist() + } + + composeTestRule.onNode(hasText("abandon") and hasTestTag(CommonTag.CHIP), useUnmergedTree = true).also { + it.assertExists() + } + + composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also { + it.assertTextEquals("") + } + } + + @Test + @MediumTest + fun invalid_phrase_does_not_progress() { + newTestSetup(generateSequence { "abandon" }.take(SeedPhrase.SEED_PHRASE_SIZE).toList()) + + composeTestRule.onNodeWithText(getStringResource(R.string.restore_complete_header)).also { + it.assertDoesNotExist() + } + } + + @Test + @MediumTest + fun finish_appears_after_24_words() { + newTestSetup(SeedPhraseFixture.new().split) + + composeTestRule.onNodeWithText(getStringResource(R.string.restore_complete_header)).also { + it.assertExists() + } + } + + @Test + @MediumTest + fun click_take_to_wallet() { + val testSetup = newTestSetup(SeedPhraseFixture.new().split) + + assertEquals(0, testSetup.getOnFinishedCount()) + + composeTestRule.onNodeWithText(getStringResource(R.string.restore_button_see_wallet)).also { + it.performClick() + } + + assertEquals(1, testSetup.getOnFinishedCount()) + } + + @Test + @MediumTest + fun back() { + val testSetup = newTestSetup() + + assertEquals(0, testSetup.getOnBackCount()) + + composeTestRule.onNodeWithContentDescription(getStringResource(R.string.restore_back_content_description)).also { + it.performClick() + } + + assertEquals(1, testSetup.getOnBackCount()) + } + + @Test + @MediumTest + fun clear() { + newTestSetup(listOf("abandon")) + + composeTestRule.onNode(hasText("abandon") and hasTestTag(CommonTag.CHIP), useUnmergedTree = true).also { + it.assertExists() + } + + composeTestRule.onNodeWithText(getStringResource(R.string.restore_button_clear)).also { + it.performClick() + } + + composeTestRule.onNode(hasText("abandon") and hasTestTag(CommonTag.CHIP), useUnmergedTree = true).also { + it.assertDoesNotExist() + } + } + + private fun newTestSetup(initialState: List = emptyList()) = TestSetup(composeTestRule, initialState) + + private class TestSetup(private val composeTestRule: ComposeContentTestRule, initialState: List) { + private val state = WordList(initialState) + + private var onBackCount = 0 + + private var onFinishedCount = 0 + + fun getUserInputWords(): List { + composeTestRule.waitForIdle() + return state.current.value + } + + fun getOnBackCount(): Int { + composeTestRule.waitForIdle() + return onBackCount + } + + fun getOnFinishedCount(): Int { + composeTestRule.waitForIdle() + return onFinishedCount + } + + init { + composeTestRule.setContent { + ZcashTheme { + RestoreWallet( + Mnemonics.getCachedWords(Locale.ENGLISH.language).toSortedSet(), + state, + onBack = { + onBackCount++ + }, + paste = { "" }, + onFinished = { + onFinishedCount++ + }, + ) + } + } + } + } +} diff --git a/ui-lib/src/main/AndroidManifest.xml b/ui-lib/src/main/AndroidManifest.xml index e13c8cbc..1376d00e 100644 --- a/ui-lib/src/main/AndroidManifest.xml +++ b/ui-lib/src/main/AndroidManifest.xml @@ -5,13 +5,14 @@ + android:supportsRtl="true"> + android:label="@string/app_name" + android:windowSoftInputMode="adjustResize" + android:theme="@style/Theme.App.Starting"/> 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 e9dd9c04..7dc9df49 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 @@ -17,7 +17,10 @@ import androidx.compose.ui.Modifier import androidx.core.content.res.ResourcesCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.lifecycleScope +import cash.z.ecc.android.sdk.type.ZcashNetwork import cash.z.ecc.sdk.model.PersistableWallet +import cash.z.ecc.sdk.model.SeedPhrase +import cash.z.ecc.sdk.type.fromResources import cash.z.ecc.ui.screen.backup.view.BackupWallet import cash.z.ecc.ui.screen.backup.viewmodel.BackupViewModel import cash.z.ecc.ui.screen.common.GradientSurface @@ -26,6 +29,9 @@ 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 +import cash.z.ecc.ui.screen.restore.view.RestoreWallet +import cash.z.ecc.ui.screen.restore.viewmodel.CompleteWordSetState +import cash.z.ecc.ui.screen.restore.viewmodel.RestoreViewModel import cash.z.ecc.ui.theme.ZcashTheme import cash.z.ecc.ui.util.AndroidApiVersion import kotlinx.coroutines.Dispatchers @@ -39,9 +45,6 @@ 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) @@ -107,15 +110,34 @@ class MainActivity : ComponentActivity() { } } + @Composable + private fun WrapOnboarding() { + val onboardingViewModel by viewModels() + + if (!onboardingViewModel.isImporting.collectAsState().value) { + Onboarding( + onboardingState = onboardingViewModel.onboardingState, + onImportWallet = { onboardingViewModel.isImporting.value = true }, + onCreateWallet = { + walletViewModel.persistNewWallet() + } + ) + } else { + WrapRestore() + } + } + @Composable private fun WrapBackup(persistableWallet: PersistableWallet) { + val backupViewModel by viewModels() + BackupWallet( persistableWallet, backupViewModel.backupState, backupViewModel.testChoices, onCopyToClipboard = { val clipboardManager = getSystemService(ClipboardManager::class.java) val data = ClipData.newPlainText( getString(R.string.new_wallet_clipboard_tag), - persistableWallet.seedPhrase.phrase + persistableWallet.seedPhrase.joinToString() ) clipboardManager.setPrimaryClip(data) }, onComplete = { @@ -125,14 +147,45 @@ class MainActivity : ComponentActivity() { } @Composable - private fun WrapOnboarding() { - Onboarding( - onboardingState = onboardingViewModel.onboardingState, - onImportWallet = { TODO("Implement wallet import") }, - onCreateWallet = { - walletViewModel.createAndPersistWallet() + private fun WrapRestore() { + val onboardingViewModel by viewModels() + val restoreViewModel by viewModels() + + when (val completeWordList = restoreViewModel.completeWordList.collectAsState().value) { + CompleteWordSetState.Loading -> { + // Although it might perform IO, it should be relatively fast. + // Consider whether to display indeterminate progress here. + // Another option would be to go straight to the restore screen with autocomplete + // disabled for a few milliseconds. Users would probably never notice due to the + // time it takes to re-orient on the new screen, unless users were doing this + // on a daily basis and become very proficient at our UI. The Therac-25 has + // historical precedent on how that could cause problems. } - ) + is CompleteWordSetState.Loaded -> { + RestoreWallet( + completeWordList.list, + restoreViewModel.userWordList, + onBack = { onboardingViewModel.isImporting.value = false }, + paste = { + val clipboardManager = getSystemService(ClipboardManager::class.java) + return@RestoreWallet clipboardManager?.primaryClip?.toString() + }, + onFinished = { + // Write the backup complete flag first, then the seed phrase. That avoids the UI + // flickering to the backup screen. Assume if a user is restoring from + // a backup, then the user has a valid backup. + walletViewModel.persistBackupComplete() + + val restoredWallet = PersistableWallet( + ZcashNetwork.fromResources(application), + null, + SeedPhrase(restoreViewModel.userWordList.current.value) + ) + walletViewModel.persistExistingWallet(restoredWallet) + } + ) + } + } } @Composable diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/common/ListExt.kt b/ui-lib/src/main/java/cash/z/ecc/ui/common/ListExt.kt new file mode 100644 index 00000000..b319f9a1 --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/common/ListExt.kt @@ -0,0 +1,3 @@ +package cash.z.ecc.ui.common + +fun List.first(count: Int) = subList(0, minOf(size, count)) 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/BackupTag.kt similarity index 90% rename from ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/BackupTags.kt rename to ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/BackupTag.kt index d17aae45..aa98543f 100644 --- 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/BackupTag.kt @@ -3,7 +3,7 @@ package cash.z.ecc.ui.screen.backup /** * These are only used for automated testing. */ -object BackupTags { +object BackupTag { 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/view/BackupView.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/backup/view/BackupView.kt index b93257a9..11b2c934 100644 --- 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 @@ -17,7 +17,7 @@ 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.BackupTag 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 @@ -123,7 +123,7 @@ private fun SeedPhrase(persistableWallet: PersistableWallet, onNext: () -> Unit, Header(stringResource(R.string.new_wallet_3_header)) Body(stringResource(R.string.new_wallet_3_body_1)) - ChipGrid(persistableWallet) + ChipGrid(persistableWallet.seedPhrase.split) PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_3_button_finished)) TertiaryButton(onClick = onCopyToClipboard, text = stringResource(R.string.new_wallet_3_button_copy)) @@ -205,7 +205,7 @@ private fun TestInProgress( choices = testChoices.map { it.word }, modifier = Modifier .weight(MINIMAL_WEIGHT) - .testTag(BackupTags.DROPDOWN_CHIP) + .testTag(BackupTag.DROPDOWN_CHIP) ) { selectedTestChoices.set( HashMap(currentSelectedTestChoice).apply { 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 index d9461cde..ac0471e2 100644 --- 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 @@ -22,7 +22,7 @@ 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.backup.BackupTag import cash.z.ecc.ui.screen.onboarding.model.Index import cash.z.ecc.ui.theme.MINIMAL_WEIGHT import cash.z.ecc.ui.theme.ZcashTheme @@ -67,7 +67,7 @@ fun ChipDropDown( ) } val dropdownModifier = if (expanded) { - Modifier.testTag(BackupTags.DROPDOWN_MENU) + Modifier.testTag(BackupTag.DROPDOWN_MENU) } else { Modifier } 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 302868d4..2857d324 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 @@ -8,6 +8,7 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import cash.z.ecc.ui.screen.onboarding.model.Index @@ -33,7 +34,10 @@ fun Chip( color = MaterialTheme.colors.secondary, elevation = 8.dp, ) { - Row(modifier = Modifier.padding(8.dp)) { + Row( + modifier = Modifier + .padding(8.dp) + ) { Text( text = (index.value + 1).toString(), style = ZcashTheme.typography.chipIndex, @@ -44,6 +48,7 @@ fun Chip( text = text, style = MaterialTheme.typography.body1, color = MaterialTheme.colors.onSecondary, + modifier = Modifier.testTag(CommonTag.CHIP) ) } } 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 index abbcd495..a5ceace9 100644 --- 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 @@ -2,27 +2,34 @@ package cash.z.ecc.ui.screen.common import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import cash.z.ecc.sdk.model.PersistableWallet +import androidx.compose.ui.platform.testTag import cash.z.ecc.ui.screen.onboarding.model.Index -import cash.z.ecc.ui.theme.MINIMAL_WEIGHT +// Note: Row size should probably change for landscape layouts const val CHIP_GRID_ROW_SIZE = 3 @Composable -fun ChipGrid(persistableWallet: PersistableWallet) { - Column { - persistableWallet.seedPhrase.split.chunked(CHIP_GRID_ROW_SIZE).forEachIndexed { chunkIndex, chunk -> +fun ChipGrid(wordList: List) { + Column(Modifier.testTag(CommonTag.CHIP_LAYOUT)) { + wordList.chunked(CHIP_GRID_ROW_SIZE).forEachIndexed { chunkIndex, chunk -> Row(Modifier.fillMaxWidth()) { + val remainder = (chunk.size % CHIP_GRID_ROW_SIZE) + val singleItemWeight = 1f / CHIP_GRID_ROW_SIZE chunk.forEachIndexed { subIndex, word -> Chip( index = Index(chunkIndex * CHIP_GRID_ROW_SIZE + subIndex), text = word, - modifier = Modifier.weight(MINIMAL_WEIGHT) + modifier = Modifier.weight(singleItemWeight) ) } + + if (0 != remainder) { + Spacer(Modifier.weight((CHIP_GRID_ROW_SIZE - chunk.size) * singleItemWeight)) + } } } } diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/common/CommonTag.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/common/CommonTag.kt new file mode 100644 index 00000000..9c09cff8 --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/common/CommonTag.kt @@ -0,0 +1,6 @@ +package cash.z.ecc.ui.screen.common + +object CommonTag { + const val CHIP_LAYOUT = "chip_layout" + const val CHIP = "chip" +} 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 dd8e0c54..6e4eab18 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 @@ -64,19 +64,6 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) WalletState.Loading ) - /** - * Persists a wallet asynchronously. Clients observe either [persistableWallet] or [synchronizer] - * 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()) - 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. @@ -85,12 +72,27 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) * 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() { + fun persistNewWallet() { val application = getApplication() viewModelScope.launch { val newWallet = PersistableWallet.new(application) - persistWallet(newWallet) + persistExistingWallet(newWallet) + } + } + + /** + * Persists a wallet asynchronously. Clients observe [state] + * 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) + } } } @@ -104,7 +106,14 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) viewModelScope.launch { val preferenceProvider = StandardPreferenceSingleton.getInstance(application) - StandardPreferenceKeys.IS_USER_BACKUP_COMPLETE.putValue(preferenceProvider, true) + + // Use the Mutex here to avoid timing issues. During wallet restore, persistBackupComplete() + // is called prior to persistExistingWallet(). Although persistBackupComplete() should + // complete quickly, it isn't guaranteed to complete before persistExistingWallet() + // unless a mutex is used here. + persistWalletMutex.withLock { + StandardPreferenceKeys.IS_USER_BACKUP_COMPLETE.putValue(preferenceProvider, true) + } } } } diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/onboarding/viewmodel/OnboardingViewModel.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/onboarding/viewmodel/OnboardingViewModel.kt index 2cb25e57..a3b5c88a 100644 --- a/ui-lib/src/main/java/cash/z/ecc/ui/screen/onboarding/viewmodel/OnboardingViewModel.kt +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/onboarding/viewmodel/OnboardingViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope import cash.z.ecc.android.sdk.ext.collectWith import cash.z.ecc.ui.screen.onboarding.model.OnboardingStage import cash.z.ecc.ui.screen.onboarding.state.OnboardingState +import kotlinx.coroutines.flow.MutableStateFlow /* * Android-specific ViewModel. This is used to save and restore state across Activity recreations @@ -31,15 +32,29 @@ class OnboardingViewModel( } } + // This is a bit weird being placed here, but onboarding currently is considered complete when + // the user has a persisted wallet. Also import allows the user to go back to onboarding, while + // creating a new wallet does not. + val isImporting = run { + val initialValue = savedStateHandle.get(KEY_IS_IMPORTING) ?: false + + MutableStateFlow(initialValue) + } + init { // viewModelScope is constructed with Dispatchers.Main.immediate, so this will // update the save state as soon as a change occurs. onboardingState.current.collectWith(viewModelScope) { savedStateHandle.set(KEY_STAGE, it) } + + isImporting.collectWith(viewModelScope) { + savedStateHandle.set(KEY_IS_IMPORTING, it) + } } companion object { private const val KEY_STAGE = "stage" // $NON-NLS + private const val KEY_IS_IMPORTING = "is_importing" // $NON-NLS } } diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/RestoreTag.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/RestoreTag.kt new file mode 100644 index 00000000..ebf888b3 --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/RestoreTag.kt @@ -0,0 +1,11 @@ +package cash.z.ecc.ui.screen.restore + +/** + * These are only used for automated testing. + */ +object RestoreTag { + const val SEED_WORD_TEXT_FIELD = "seed_text_field" + const val CHIP_LAYOUT = "chip_group" + const val AUTOCOMPLETE_LAYOUT = "autocomplete_layout" + const val AUTOCOMPLETE_ITEM = "autocomplete_item" +} diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/model/ParseResult.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/model/ParseResult.kt new file mode 100644 index 00000000..b9efbf53 --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/model/ParseResult.kt @@ -0,0 +1,63 @@ +package cash.z.ecc.ui.screen.restore.model + +import cash.z.ecc.sdk.model.SeedPhrase +import cash.z.ecc.ui.common.first +import java.util.Locale + +internal sealed class ParseResult { + object Continue : ParseResult() + data class Add(val words: List) : ParseResult() { + // Override to prevent logging of user secrets + override fun toString() = "Add" + } + + data class Autocomplete(val suggestions: List) : ParseResult() { + // Override to prevent logging of user secrets + override fun toString() = "Autocomplete" + } + + data class Warn(val suggestions: List) : ParseResult() { + // Override to prevent logging of user secrets + override fun toString() = "Warn" + } + + companion object { + @Suppress("ReturnCount") + fun new(completeWordList: Set, rawInput: String): ParseResult { + // Note: This assumes the word list is English words + val trimmed = rawInput.lowercase(Locale.US).trim() + + if (trimmed.isBlank()) { + return Continue + } + + if (completeWordList.contains(trimmed)) { + return Add(listOf(trimmed)) + } + + val autocomplete = completeWordList.filter { it.startsWith(trimmed) } + if (autocomplete.isNotEmpty()) { + return Autocomplete(autocomplete) + } + + val multiple = trimmed.split(SeedPhrase.DEFAULT_DELIMITER) + .filter { completeWordList.contains(it) } + .first(SeedPhrase.SEED_PHRASE_SIZE) + if (multiple.isNotEmpty()) { + return Add(multiple) + } + + return Warn(findSuggestions(trimmed, completeWordList)) + } + } +} + +internal fun findSuggestions(input: String, completeWordList: Set): List { + return if (input.isBlank()) { + emptyList() + } else { + completeWordList.filter { it.startsWith(input) }.ifEmpty { + findSuggestions(input.dropLast(1), completeWordList) + } + } +} diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/state/WordList.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/state/WordList.kt new file mode 100644 index 00000000..3dfb9cca --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/state/WordList.kt @@ -0,0 +1,26 @@ +package cash.z.ecc.ui.screen.restore.state + +import cash.z.ecc.sdk.model.SeedPhraseValidation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map + +class WordList(initial: List = emptyList()) { + private val mutableState = MutableStateFlow(initial) + + val current: StateFlow> = mutableState + + fun set(list: List) { + mutableState.value = ArrayList(list) + } + + fun append(words: List) { + mutableState.value = ArrayList(current.value) + words + } + + // Custom toString to prevent leaking word list + override fun toString() = "WordList" +} + +fun WordList.wordValidation() = current + .map { SeedPhraseValidation.new(it) } diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/view/RestoreView.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/view/RestoreView.kt new file mode 100644 index 00000000..3bb56218 --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/view/RestoreView.kt @@ -0,0 +1,354 @@ +package cash.z.ecc.ui.screen.restore.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import cash.z.ecc.sdk.model.SeedPhraseValidation +import cash.z.ecc.ui.R +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.CommonTag +import cash.z.ecc.ui.screen.common.GradientSurface +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.onboarding.model.Index +import cash.z.ecc.ui.screen.restore.RestoreTag +import cash.z.ecc.ui.screen.restore.model.ParseResult +import cash.z.ecc.ui.screen.restore.state.WordList +import cash.z.ecc.ui.screen.restore.state.wordValidation +import cash.z.ecc.ui.theme.MINIMAL_WEIGHT +import cash.z.ecc.ui.theme.ZcashTheme + +@Preview("Restore Wallet") +@Composable +fun PreviewRestore() { + ZcashTheme(darkTheme = true) { + GradientSurface { + RestoreWallet( + completeWordList = setOf( + "abandon", + "ability", + "able", + "about", + "above", + "absent", + "absorb", + "abstract" + ), + userWordList = WordList(listOf("abandon", "absorb")), + onBack = {}, + paste = { "" }, + onFinished = {} + ) + } + } +} + +@Preview("Restore Complete") +@Composable +fun PreviewRestoreComplete() { + ZcashTheme(darkTheme = true) { + RestoreComplete( + onComplete = {} + ) + } +} + +@Suppress("UNUSED_PARAMETER") +@Composable +fun RestoreWallet( + completeWordList: Set, + userWordList: WordList, + onBack: () -> Unit, + paste: () -> String?, + onFinished: () -> Unit +) { + userWordList.wordValidation().collectAsState(null).value?.let { seedPhraseValidation -> + if (seedPhraseValidation !is SeedPhraseValidation.Valid) { + Scaffold(topBar = { + RestoreTopAppBar(onBack = onBack, onClear = { userWordList.set(emptyList()) }) + }) { + RestoreMainContent(completeWordList, userWordList, paste) + } + } else { + RestoreComplete(onComplete = onFinished) + } + } +} + +@Composable +private fun RestoreTopAppBar(onBack: () -> Unit, onClear: () -> Unit) { + TopAppBar { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + IconButton( + modifier = Modifier.padding(16.dp), + onClick = onBack + ) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = stringResource(R.string.restore_back_content_description) + ) + } + + Text(text = stringResource(id = R.string.restore_header)) + + Spacer( + Modifier + .fillMaxWidth() + .weight(MINIMAL_WEIGHT) + ) + + NavigationButton(onClick = onClear, stringResource(R.string.restore_button_clear)) + } + } +} + +@Suppress("UNUSED_PARAMETER") +@Composable +private fun RestoreMainContent( + completeWordList: Set, + userWordList: WordList, + paste: () -> String? +) { + var textState by rememberSaveable { mutableStateOf("") } + + val currentUserWordList = userWordList.current.collectAsState().value + + val parseResult = ParseResult.new(completeWordList, textState) + + if (parseResult is ParseResult.Add) { + textState = "" + userWordList.append(parseResult.words) + } + + val focusRequester = remember { FocusRequester() } + + Column { + Text(text = stringResource(id = R.string.restore_instructions)) + + Box( + Modifier + .fillMaxHeight() + .fillMaxWidth() + .weight(MINIMAL_WEIGHT) + ) { + Column( + Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + ChipGridWithText(currentUserWordList, textState, { textState = it }, focusRequester) + Spacer( + Modifier + .fillMaxHeight() + .weight(MINIMAL_WEIGHT) + ) + } + + // Must come after the grid in order for its Z ordering to be on top + Warn(parseResult) + + Autocomplete(Modifier.align(Alignment.BottomStart), parseResult) { + textState = "" + userWordList.append(listOf(it)) + focusRequester.requestFocus() + } + } + } + + // Cause text field to refocus + DisposableEffect(parseResult) { + focusRequester.requestFocus() + onDispose { } + } +} + +@Composable +private fun ChipGridWithText( + userWordList: List, + text: String, + setText: (String) -> Unit, + focusRequester: FocusRequester +) { + val isTextFieldOnNewLine = userWordList.size % CHIP_GRID_ROW_SIZE == 0 + + val scrollState = rememberScrollState() + + Column( + Modifier + .verticalScroll(scrollState) + .testTag(CommonTag.CHIP_LAYOUT) + ) { + userWordList.chunked(CHIP_GRID_ROW_SIZE).forEachIndexed { chunkIndex, chunk -> + Row(Modifier.fillMaxWidth()) { + val remainder = (chunk.size % CHIP_GRID_ROW_SIZE) + + val singleItemWeight = 1f / CHIP_GRID_ROW_SIZE + chunk.forEachIndexed { subIndex, word -> + Chip( + index = Index(chunkIndex * CHIP_GRID_ROW_SIZE + subIndex), + text = word, + modifier = Modifier.weight(singleItemWeight) + ) + } + + if (0 != remainder) { + NextWordTextField( + Modifier + .focusRequester(focusRequester) + .weight((CHIP_GRID_ROW_SIZE - chunk.size) * singleItemWeight), + text, setText + ) + } + } + } + + if (isTextFieldOnNewLine) { + NextWordTextField(Modifier.focusRequester(focusRequester), text = text, setText = setText) + } + } +} + +@Composable +private fun NextWordTextField(modifier: Modifier = Modifier, text: String, setText: (String) -> Unit) { + /* + * Treat the user input as a password, but disable the transformation to obscure input. + */ + TextField( + value = text, onValueChange = setText, + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Max) + .testTag(RestoreTag.SEED_WORD_TEXT_FIELD), + visualTransformation = VisualTransformation.None, + keyboardOptions = KeyboardOptions( + KeyboardCapitalization.None, + autoCorrect = false, imeAction = ImeAction.Done, keyboardType = KeyboardType.Password + ), + keyboardActions = KeyboardActions(onAny = {}) + ) +} + +@Composable +private fun Autocomplete( + modifier: Modifier = Modifier, + parseResult: ParseResult, + onSuggestionSelected: (String) -> Unit +) { + val (isHighlight, suggestions) = when (parseResult) { + is ParseResult.Autocomplete -> { + Pair(false, parseResult.suggestions) + } + is ParseResult.Warn -> { + Pair(true, parseResult.suggestions) + } + else -> { + Pair(false, null) + } + } + suggestions?.let { + val highlightModifier = if (isHighlight) { + modifier.border(2.dp, ZcashTheme.colors.highlight) + } else { + modifier + } + + LazyRow(highlightModifier.testTag(RestoreTag.AUTOCOMPLETE_LAYOUT)) { + items(it) { + Button( + modifier = Modifier.testTag(RestoreTag.AUTOCOMPLETE_ITEM), + onClick = { onSuggestionSelected(it) } + ) { + Text(it) + } + } + } + } +} + +@Composable +private fun Warn(parseResult: ParseResult) { + if (parseResult is ParseResult.Warn) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Spacer( + Modifier + .matchParentSize() + .background(ZcashTheme.colors.overlay) + ) + + if (parseResult.suggestions.isEmpty()) { + Text(stringResource(id = R.string.restore_warning_no_suggestions)) + } else { + Text(stringResource(id = R.string.restore_warning_suggestions)) + } + } + } +} + +@Composable +private fun RestoreComplete(onComplete: () -> Unit) { + Column { + Header(stringResource(R.string.restore_complete_header)) + Body(stringResource(R.string.restore_complete_info)) + Spacer( + modifier = Modifier + .fillMaxHeight() + .weight(MINIMAL_WEIGHT) + ) + PrimaryButton(onComplete, stringResource(R.string.restore_button_see_wallet)) + // TODO [#151]: Add option to provide wallet birthday + } +} diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/viewmodel/RestoreViewModel.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/viewmodel/RestoreViewModel.kt new file mode 100644 index 00000000..4d0019b3 --- /dev/null +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/restore/viewmodel/RestoreViewModel.kt @@ -0,0 +1,68 @@ +package cash.z.ecc.ui.screen.restore.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import cash.z.ecc.android.bip39.Mnemonics +import cash.z.ecc.android.sdk.ext.collectWith +import cash.z.ecc.ui.common.ANDROID_STATE_FLOW_TIMEOUT_MILLIS +import cash.z.ecc.ui.screen.restore.state.WordList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import java.util.Locale +import java.util.TreeSet + +class RestoreViewModel(application: Application, savedStateHandle: SavedStateHandle) : AndroidViewModel(application) { + + /** + * The complete word list that the user can choose from; useful for autocomplete + */ + // This is a hack to prevent disk IO on the main thread + val completeWordList = flow { + // Using IO context because of https://github.com/zcash/kotlin-bip39/issues/13 + val completeWordList = withContext(Dispatchers.IO) { + Mnemonics.getCachedWords(Locale.ENGLISH.language) + } + + emit(CompleteWordSetState.Loaded(TreeSet(completeWordList))) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT_MILLIS), + CompleteWordSetState.Loading + ) + + val userWordList: WordList = run { + val initialValue = if (savedStateHandle.contains(KEY_WORD_LIST)) { + savedStateHandle.get>(KEY_WORD_LIST) + } else { + null + } + + if (null == initialValue) { + WordList() + } else { + WordList(initialValue) + } + } + + init { + // viewModelScope is constructed with Dispatchers.Main.immediate, so this will + // update the save state as soon as a change occurs. + userWordList.current.collectWith(viewModelScope) { + savedStateHandle.set(KEY_WORD_LIST, it) + } + } + + companion object { + private const val KEY_WORD_LIST = "word_list" // $NON-NLS + } +} + +sealed class CompleteWordSetState { + object Loading : CompleteWordSetState() + data class Loaded(val list: Set) : CompleteWordSetState() +} diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/theme/Color.kt b/ui-lib/src/main/java/cash/z/ecc/ui/theme/Color.kt index 8957fa91..6c6bf218 100644 --- a/ui-lib/src/main/java/cash/z/ecc/ui/theme/Color.kt +++ b/ui-lib/src/main/java/cash/z/ecc/ui/theme/Color.kt @@ -38,6 +38,9 @@ object Dark { val callout = Color(0xFFa7bed8) val onCallout = Color(0xFF3d698f) + + val overlay = Color(0x22000000) + val highlight = Color(0xFFFFD800) } object Light { @@ -74,4 +77,7 @@ object Light { val callout = Color(0xFFe6f0f9) val onCallout = Color(0xFFa1b8d0) + + val overlay = Color(0x22000000) + val highlight = Color(0xFFFFD800) } diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/theme/Theme.kt b/ui-lib/src/main/java/cash/z/ecc/ui/theme/Theme.kt index d49ee6b0..eb7cae9d 100644 --- a/ui-lib/src/main/java/cash/z/ecc/ui/theme/Theme.kt +++ b/ui-lib/src/main/java/cash/z/ecc/ui/theme/Theme.kt @@ -30,7 +30,7 @@ private val LightColorPalette = lightColors( surface = Light.backgroundStart, onSurface = Light.textBodyOnBackground, background = Light.backgroundStart, - onBackground = Light.textBodyOnBackground + onBackground = Light.textBodyOnBackground, ) @Immutable @@ -44,7 +44,9 @@ data class ExtendedColors( val progressStart: Color, val progressEnd: Color, val progressBackground: Color, - val chipIndex: Color + val chipIndex: Color, + val overlay: Color, + val highlight: Color ) { @Composable fun surfaceGradient() = Brush.verticalGradient( @@ -65,7 +67,9 @@ val DarkExtendedColorPalette = ExtendedColors( progressStart = Dark.progressStart, progressEnd = Dark.progressEnd, progressBackground = Dark.progressBackground, - chipIndex = Dark.textChipIndex + chipIndex = Dark.textChipIndex, + overlay = Dark.overlay, + highlight = Dark.highlight ) val LightExtendedColorPalette = ExtendedColors( @@ -78,7 +82,9 @@ val LightExtendedColorPalette = ExtendedColors( progressStart = Light.progressStart, progressEnd = Light.progressEnd, progressBackground = Light.progressBackground, - chipIndex = Light.textChipIndex + chipIndex = Light.textChipIndex, + overlay = Light.overlay, + highlight = Light.highlight ) val LocalExtendedColors = staticCompositionLocalOf { @@ -92,7 +98,9 @@ val LocalExtendedColors = staticCompositionLocalOf { progressStart = Color.Unspecified, progressEnd = Color.Unspecified, progressBackground = Color.Unspecified, - chipIndex = Color.Unspecified + chipIndex = Color.Unspecified, + overlay = Color.Unspecified, + highlight = Color.Unspecified ) } diff --git a/ui-lib/src/main/res/ui/restore/values/strings.xml b/ui-lib/src/main/res/ui/restore/values/strings.xml new file mode 100644 index 00000000..e65e4863 --- /dev/null +++ b/ui-lib/src/main/res/ui/restore/values/strings.xml @@ -0,0 +1,15 @@ + + Restore Wallet + Back + Clear + You will need to enter all 24 seed words. Don’t worry, we’ll autocomplete them as you type. + + This word is not in the seed phrase dictionary. Please select the correct one from the suggestions. + This word is not in the seed phrase dictionary. + + Seed phrase imported! + We will now scan the blockchain to find your transactions and balance. You can do this faster by adding a wallet birthday below. + Take me to my wallet + Add a wallet birthday + + From b03d1500ee1ce9ebaf5f0d525c5070d44dd2eef8 Mon Sep 17 00:00:00 2001 From: Carter Jernigan Date: Mon, 20 Dec 2021 08:18:06 -0500 Subject: [PATCH 2/2] Address review comments --- ui-lib/src/main/java/cash/z/ecc/ui/MainActivity.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 7dc9df49..69402edd 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 @@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier import androidx.core.content.res.ResourcesCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.lifecycleScope +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 @@ -176,9 +177,10 @@ class MainActivity : ComponentActivity() { // a backup, then the user has a valid backup. walletViewModel.persistBackupComplete() + val network = ZcashNetwork.fromResources(application) val restoredWallet = PersistableWallet( - ZcashNetwork.fromResources(application), - null, + network, + WalletBirthday(network.saplingActivationHeight), SeedPhrase(restoreViewModel.userWordList.current.value) ) walletViewModel.persistExistingWallet(restoredWallet)