[#17] Import wallet

This commit is contained in:
Carter Jernigan 2021-12-09 15:21:30 -05:00
parent 45b29c59d1
commit 2e40af9390
36 changed files with 1238 additions and 93 deletions

View File

@ -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

View File

@ -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

View File

@ -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"
}
}
}

View File

@ -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))
}
}

View File

@ -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))
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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() })

View File

@ -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<String>) {
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))
}
}

View File

@ -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<String>): 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
}
}
}
}

View File

@ -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"
)
)
}

View File

@ -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))
}
}

View File

@ -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())

View File

@ -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"))
}
}

View File

@ -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)
}
}

View File

@ -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<Context>().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<String> = emptyList()) = TestSetup(composeTestRule, initialState)
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initialState: List<String>) {
private val state = WordList(initialState)
private var onBackCount = 0
private var onFinishedCount = 0
fun getUserInputWords(): List<String> {
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++
},
)
}
}
}
}
}

View File

@ -5,13 +5,14 @@
<application
android:icon="@mipmap/ic_launcher_square"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.App.Starting">
android:supportsRtl="true">
<activity
android:name="cash.z.ecc.ui.MainActivity"
android:exported="false"
android:label="@string/app_name" />
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.App.Starting"/>
</application>
</manifest>

View File

@ -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<WalletViewModel>()
private val onboardingViewModel by viewModels<OnboardingViewModel>()
private val backupViewModel by viewModels<BackupViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -107,15 +110,34 @@ class MainActivity : ComponentActivity() {
}
}
@Composable
private fun WrapOnboarding() {
val onboardingViewModel by viewModels<OnboardingViewModel>()
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<BackupViewModel>()
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<OnboardingViewModel>()
val restoreViewModel by viewModels<RestoreViewModel>()
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

View File

@ -0,0 +1,3 @@
package cash.z.ecc.ui.common
fun <T> List<T>.first(count: Int) = subList(0, minOf(size, count))

View File

@ -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"
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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)
)
}
}

View File

@ -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<String>) {
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))
}
}
}
}

View File

@ -0,0 +1,6 @@
package cash.z.ecc.ui.screen.common
object CommonTag {
const val CHIP_LAYOUT = "chip_layout"
const val CHIP = "chip"
}

View File

@ -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<Application>()
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<Application>()
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)
}
}
}
}

View File

@ -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<Boolean?>(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
}
}

View File

@ -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"
}

View File

@ -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<String>) : ParseResult() {
// Override to prevent logging of user secrets
override fun toString() = "Add"
}
data class Autocomplete(val suggestions: List<String>) : ParseResult() {
// Override to prevent logging of user secrets
override fun toString() = "Autocomplete"
}
data class Warn(val suggestions: List<String>) : ParseResult() {
// Override to prevent logging of user secrets
override fun toString() = "Warn"
}
companion object {
@Suppress("ReturnCount")
fun new(completeWordList: Set<String>, 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<String>): List<String> {
return if (input.isBlank()) {
emptyList()
} else {
completeWordList.filter { it.startsWith(input) }.ifEmpty {
findSuggestions(input.dropLast(1), completeWordList)
}
}
}

View File

@ -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<String> = emptyList()) {
private val mutableState = MutableStateFlow(initial)
val current: StateFlow<List<String>> = mutableState
fun set(list: List<String>) {
mutableState.value = ArrayList(list)
}
fun append(words: List<String>) {
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) }

View File

@ -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<String>,
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<String>,
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<String>,
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
}
}

View File

@ -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<CompleteWordSetState> {
// 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<ArrayList<String>>(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<String>) : CompleteWordSetState()
}

View File

@ -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)
}

View File

@ -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
)
}

View File

@ -0,0 +1,15 @@
<resources>
<string name="restore_header">Restore Wallet</string>
<string name="restore_back_content_description">Back</string>
<string name="restore_button_clear">Clear</string>
<string name="restore_instructions">You will need to enter all 24 seed words. Dont worry, well autocomplete them as you type.</string>
<string name="restore_warning_suggestions">This word is not in the seed phrase dictionary. Please select the correct one from the suggestions.</string>
<string name="restore_warning_no_suggestions">This word is not in the seed phrase dictionary.</string>
<string name="restore_complete_header">Seed phrase imported!</string>
<string name="restore_complete_info">We will now scan the blockchain to find your transactions and balance. You can do this faster by adding a wallet birthday below.</string>
<string name="restore_button_see_wallet">Take me to my wallet</string>
<string name="restore_button_add_birthday">Add a wallet birthday</string>
</resources>