Merge pull request #152 from zcash/17-import-wallet
[#17] Import wallet
This commit is contained in:
commit
a3a30792c3
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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() })
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
|
|
|
@ -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"))
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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++
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -17,7 +17,11 @@ 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
|
||||
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 +30,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 +46,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 +111,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 +148,46 @@ 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 network = ZcashNetwork.fromResources(application)
|
||||
val restoredWallet = PersistableWallet(
|
||||
network,
|
||||
WalletBirthday(network.saplingActivationHeight),
|
||||
SeedPhrase(restoreViewModel.userWordList.current.value)
|
||||
)
|
||||
walletViewModel.persistExistingWallet(restoredWallet)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
package cash.z.ecc.ui.common
|
||||
|
||||
fun <T> List<T>.first(count: Int) = subList(0, minOf(size, count))
|
|
@ -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"
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
package cash.z.ecc.ui.screen.common
|
||||
|
||||
object CommonTag {
|
||||
const val CHIP_LAYOUT = "chip_layout"
|
||||
const val CHIP = "chip"
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) }
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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. Don’t worry, we’ll 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>
|
Loading…
Reference in New Issue