[#151] Add birthday to restore
This commit is contained in:
parent
0c0bf8cb34
commit
84b40fc5fa
|
@ -7,11 +7,10 @@ import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||||
|
|
||||||
object PersistableWalletFixture {
|
object PersistableWalletFixture {
|
||||||
|
|
||||||
val NETWORK = ZcashNetwork.Testnet
|
val NETWORK = ZcashNetwork.Mainnet
|
||||||
|
|
||||||
// These came from the mainnet 1500000.json file
|
|
||||||
@Suppress("MagicNumber")
|
@Suppress("MagicNumber")
|
||||||
val BIRTHDAY = BlockHeight.new(ZcashNetwork.Mainnet, 1500000L)
|
val BIRTHDAY = BlockHeight.new(ZcashNetwork.Mainnet, 626603L)
|
||||||
|
|
||||||
val SEED_PHRASE = SeedPhraseFixture.new()
|
val SEED_PHRASE = SeedPhraseFixture.new()
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ sealed class SeedPhraseValidation {
|
||||||
@Suppress("SwallowedException")
|
@Suppress("SwallowedException")
|
||||||
return try {
|
return try {
|
||||||
val stringified = list.joinToString(SeedPhrase.DEFAULT_DELIMITER)
|
val stringified = list.joinToString(SeedPhrase.DEFAULT_DELIMITER)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Default) {
|
||||||
Mnemonics.MnemonicCode(stringified, Locale.ENGLISH.language).validate()
|
Mnemonics.MnemonicCode(stringified, Locale.ENGLISH.language).validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,126 @@
|
||||||
|
package co.electriccoin.zcash.ui.screen.restore.view
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build.VERSION_CODES
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
|
import androidx.compose.ui.test.hasTestTag
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.performTextInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import androidx.compose.ui.test.withKeyDown
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
|
import androidx.test.filters.SdkSuppress
|
||||||
|
import cash.z.ecc.android.sdk.model.SeedPhrase
|
||||||
|
import cash.z.ecc.sdk.fixture.SeedPhraseFixture
|
||||||
|
import co.electriccoin.zcash.test.UiTestPrerequisites
|
||||||
|
import co.electriccoin.zcash.ui.R
|
||||||
|
import co.electriccoin.zcash.ui.design.component.CommonTag
|
||||||
|
import co.electriccoin.zcash.ui.screen.restore.RestoreTag
|
||||||
|
import co.electriccoin.zcash.ui.screen.restore.model.RestoreStage
|
||||||
|
import co.electriccoin.zcash.ui.test.getAppContext
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
|
||||||
|
// Non-multiplatform tests that require interacting with the Android system (e.g. clipboard, Context)
|
||||||
|
// These don't have persistent state, so they are still unit tests.
|
||||||
|
class RestoreViewAndroidTest : UiTestPrerequisites() {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
fun keyboard_appears_on_launch() {
|
||||||
|
newTestSetup()
|
||||||
|
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
|
||||||
|
it.assertIsFocused()
|
||||||
|
}
|
||||||
|
|
||||||
|
val inputMethodManager = getAppContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
assertTrue(inputMethodManager.isAcceptingText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class, ExperimentalComposeUiApi::class)
|
||||||
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
// Functionality should be compatible with Android 27+, but a bug in the Android framework causes a crash
|
||||||
|
// on Android 27. Further, copying to the clipboard seems to be broken in emulators until API 33 (even in
|
||||||
|
// other apps like the Contacts app). We haven't been able to test this on physical devices yet, but
|
||||||
|
// we're assuming that it works.
|
||||||
|
@SdkSuppress(minSdkVersion = VERSION_CODES.TIRAMISU)
|
||||||
|
fun paste_too_many_words() {
|
||||||
|
val testSetup = newTestSetup()
|
||||||
|
|
||||||
|
copyToClipboard(
|
||||||
|
getAppContext(),
|
||||||
|
SeedPhraseFixture.SEED_PHRASE + " " + SeedPhraseFixture.SEED_PHRASE
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
|
||||||
|
it.performKeyInput {
|
||||||
|
withKeyDown(Key.CtrlLeft) {
|
||||||
|
pressKey(Key.V)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(SeedPhrase.SEED_PHRASE_SIZE, testSetup.getUserInputWords().size)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
|
||||||
|
it.assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag(RestoreTag.AUTOCOMPLETE_LAYOUT).also {
|
||||||
|
it.assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onAllNodes(hasTestTag(CommonTag.CHIP), useUnmergedTree = true).also {
|
||||||
|
it.assertCountEquals(SeedPhrase.SEED_PHRASE_SIZE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
fun keyboard_disappears_after_seed() {
|
||||||
|
newTestSetup()
|
||||||
|
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
|
||||||
|
it.performTextInput(SeedPhraseFixture.SEED_PHRASE)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
val inputMethodManager = getAppContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
assertFalse(inputMethodManager.isAcceptingText)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newTestSetup(
|
||||||
|
initialStage: RestoreStage = RestoreStage.Seed,
|
||||||
|
initialWordsList: List<String> = emptyList()
|
||||||
|
) = RestoreViewTest.TestSetup(composeTestRule, initialStage, initialWordsList)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyToClipboard(context: Context, text: String) {
|
||||||
|
val clipboardManager = context.getSystemService(ClipboardManager::class.java)
|
||||||
|
val data = ClipData.newPlainText(
|
||||||
|
context.getString(R.string.new_wallet_clipboard_tag),
|
||||||
|
text
|
||||||
|
)
|
||||||
|
clipboardManager.setPrimaryClip(data)
|
||||||
|
}
|
|
@ -5,10 +5,12 @@ import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
import cash.z.ecc.android.bip39.Mnemonics
|
import cash.z.ecc.android.bip39.Mnemonics
|
||||||
|
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||||
import co.electriccoin.zcash.test.UiTestPrerequisites
|
import co.electriccoin.zcash.test.UiTestPrerequisites
|
||||||
import co.electriccoin.zcash.ui.common.LocalScreenSecurity
|
import co.electriccoin.zcash.ui.common.LocalScreenSecurity
|
||||||
import co.electriccoin.zcash.ui.common.ScreenSecurity
|
import co.electriccoin.zcash.ui.common.ScreenSecurity
|
||||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||||
|
import co.electriccoin.zcash.ui.screen.restore.state.RestoreState
|
||||||
import co.electriccoin.zcash.ui.screen.restore.state.WordList
|
import co.electriccoin.zcash.ui.screen.restore.state.WordList
|
||||||
import kotlinx.collections.immutable.toPersistentSet
|
import kotlinx.collections.immutable.toPersistentSet
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
@ -41,8 +43,12 @@ class RestoreViewSecuredScreenTest : UiTestPrerequisites() {
|
||||||
CompositionLocalProvider(LocalScreenSecurity provides screenSecurity) {
|
CompositionLocalProvider(LocalScreenSecurity provides screenSecurity) {
|
||||||
ZcashTheme {
|
ZcashTheme {
|
||||||
RestoreWallet(
|
RestoreWallet(
|
||||||
|
ZcashNetwork.Mainnet,
|
||||||
|
RestoreState(),
|
||||||
Mnemonics.getCachedWords(Locale.ENGLISH.language).toPersistentSet(),
|
Mnemonics.getCachedWords(Locale.ENGLISH.language).toPersistentSet(),
|
||||||
WordList(emptyList()),
|
WordList(emptyList()),
|
||||||
|
restoreHeight = null,
|
||||||
|
setRestoreHeight = {},
|
||||||
onBack = { },
|
onBack = { },
|
||||||
paste = { "" },
|
paste = { "" },
|
||||||
onFinished = { }
|
onFinished = { }
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
package co.electriccoin.zcash.ui.screen.restore.view
|
package co.electriccoin.zcash.ui.screen.restore.view
|
||||||
|
|
||||||
import android.content.Context
|
import androidx.compose.runtime.collectAsState
|
||||||
import android.view.inputmethod.InputMethodManager
|
import androidx.compose.ui.test.assertIsEnabled
|
||||||
import androidx.compose.ui.test.assertIsFocused
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
import androidx.compose.ui.test.assertTextContains
|
import androidx.compose.ui.test.assertTextContains
|
||||||
import androidx.compose.ui.test.assertTextEquals
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
import androidx.compose.ui.test.hasTestTag
|
import androidx.compose.ui.test.hasTestTag
|
||||||
|
@ -16,23 +16,27 @@ import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performTextInput
|
import androidx.compose.ui.test.performTextInput
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
import cash.z.ecc.android.bip39.Mnemonics
|
import cash.z.ecc.android.bip39.Mnemonics
|
||||||
|
import cash.z.ecc.android.sdk.model.BlockHeight
|
||||||
import cash.z.ecc.android.sdk.model.SeedPhrase
|
import cash.z.ecc.android.sdk.model.SeedPhrase
|
||||||
|
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||||
import cash.z.ecc.sdk.fixture.SeedPhraseFixture
|
import cash.z.ecc.sdk.fixture.SeedPhraseFixture
|
||||||
import co.electriccoin.zcash.test.UiTestPrerequisites
|
import co.electriccoin.zcash.test.UiTestPrerequisites
|
||||||
import co.electriccoin.zcash.ui.R
|
import co.electriccoin.zcash.ui.R
|
||||||
import co.electriccoin.zcash.ui.design.component.CommonTag
|
import co.electriccoin.zcash.ui.design.component.CommonTag
|
||||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||||
import co.electriccoin.zcash.ui.screen.restore.RestoreTag
|
import co.electriccoin.zcash.ui.screen.restore.RestoreTag
|
||||||
|
import co.electriccoin.zcash.ui.screen.restore.model.RestoreStage
|
||||||
|
import co.electriccoin.zcash.ui.screen.restore.state.RestoreState
|
||||||
import co.electriccoin.zcash.ui.screen.restore.state.WordList
|
import co.electriccoin.zcash.ui.screen.restore.state.WordList
|
||||||
import co.electriccoin.zcash.ui.test.getAppContext
|
|
||||||
import co.electriccoin.zcash.ui.test.getStringResource
|
import co.electriccoin.zcash.ui.test.getStringResource
|
||||||
import kotlinx.collections.immutable.toPersistentSet
|
import kotlinx.collections.immutable.toPersistentSet
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
class RestoreViewTest : UiTestPrerequisites() {
|
class RestoreViewTest : UiTestPrerequisites() {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
|
@ -40,23 +44,8 @@ class RestoreViewTest : UiTestPrerequisites() {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@MediumTest
|
@MediumTest
|
||||||
fun keyboard_appears_on_launch() {
|
fun seed_autocomplete_suggestions_appear() {
|
||||||
newTestSetup(emptyList())
|
newTestSetup()
|
||||||
|
|
||||||
composeTestRule.waitForIdle()
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
|
|
||||||
it.assertIsFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
val inputMethodManager = getAppContext().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 {
|
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
|
||||||
it.performTextInput("ab")
|
it.performTextInput("ab")
|
||||||
|
@ -76,8 +65,8 @@ class RestoreViewTest : UiTestPrerequisites() {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@MediumTest
|
@MediumTest
|
||||||
fun choose_autocomplete() {
|
fun seed_choose_autocomplete() {
|
||||||
newTestSetup(emptyList())
|
newTestSetup()
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
|
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
|
||||||
it.performTextInput("ab")
|
it.performTextInput("ab")
|
||||||
|
@ -102,8 +91,8 @@ class RestoreViewTest : UiTestPrerequisites() {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@MediumTest
|
@MediumTest
|
||||||
fun type_full_word() {
|
fun seed_type_full_word() {
|
||||||
newTestSetup(emptyList())
|
newTestSetup()
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
|
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
|
||||||
it.performTextInput("abandon")
|
it.performTextInput("abandon")
|
||||||
|
@ -128,56 +117,28 @@ class RestoreViewTest : UiTestPrerequisites() {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@MediumTest
|
@MediumTest
|
||||||
fun invalid_phrase_does_not_progress() {
|
fun seed_invalid_phrase_does_not_progress() {
|
||||||
newTestSetup(generateSequence { "abandon" }.take(SeedPhrase.SEED_PHRASE_SIZE).toList())
|
newTestSetup(initialWordsList = generateSequence { "abandon" }.take(SeedPhrase.SEED_PHRASE_SIZE).toList())
|
||||||
|
|
||||||
composeTestRule.onNodeWithText(getStringResource(R.string.restore_complete_header)).also {
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_seed_button_restore)).also {
|
||||||
it.assertDoesNotExist()
|
it.assertIsNotEnabled()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@MediumTest
|
@MediumTest
|
||||||
fun finish_appears_after_24_words() {
|
fun seed_finish_appears_after_24_words() {
|
||||||
newTestSetup(SeedPhraseFixture.new().split)
|
newTestSetup(initialWordsList = SeedPhraseFixture.new().split)
|
||||||
|
|
||||||
composeTestRule.onNodeWithText(getStringResource(R.string.restore_complete_header)).also {
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_seed_button_restore)).also {
|
||||||
it.assertExists()
|
it.assertExists()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@MediumTest
|
@MediumTest
|
||||||
fun click_take_to_wallet() {
|
fun seed_clear() {
|
||||||
val testSetup = newTestSetup(SeedPhraseFixture.new().split)
|
newTestSetup(initialWordsList = listOf("abandon"))
|
||||||
|
|
||||||
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 {
|
composeTestRule.onNode(hasText("abandon") and hasTestTag(CommonTag.CHIP), useUnmergedTree = true).also {
|
||||||
it.assertExists()
|
it.assertExists()
|
||||||
|
@ -192,20 +153,226 @@ class RestoreViewTest : UiTestPrerequisites() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun newTestSetup(initialState: List<String> = emptyList()) = TestSetup(composeTestRule, initialState)
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
fun height_skip() {
|
||||||
|
val testSetup = newTestSetup(initialStage = RestoreStage.Birthday, initialWordsList = SeedPhraseFixture.new().split)
|
||||||
|
|
||||||
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initialState: List<String>) {
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_birthday_button_skip)).also {
|
||||||
private val state = WordList(initialState)
|
it.performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(testSetup.getRestoreHeight(), null)
|
||||||
|
assertEquals(testSetup.getStage(), RestoreStage.Complete)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
fun height_set_valid() {
|
||||||
|
val testSetup = newTestSetup(
|
||||||
|
initialStage = RestoreStage.Birthday,
|
||||||
|
initialWordsList = SeedPhraseFixture.new().split
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_birthday_button_restore)).also {
|
||||||
|
it.assertIsNotEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_birthday_hint)).also {
|
||||||
|
it.performTextInput(ZcashNetwork.Mainnet.saplingActivationHeight.value.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_birthday_button_restore)).also {
|
||||||
|
it.assertIsEnabled()
|
||||||
|
it.performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(testSetup.getRestoreHeight(), ZcashNetwork.Mainnet.saplingActivationHeight)
|
||||||
|
assertEquals(testSetup.getStage(), RestoreStage.Complete)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
fun height_set_valid_but_skip() {
|
||||||
|
val testSetup = newTestSetup(
|
||||||
|
initialStage = RestoreStage.Birthday,
|
||||||
|
initialWordsList = SeedPhraseFixture.new().split
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_birthday_button_restore)).also {
|
||||||
|
it.assertIsNotEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_birthday_hint)).also {
|
||||||
|
it.performTextInput(ZcashNetwork.Mainnet.saplingActivationHeight.value.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_birthday_button_skip)).also {
|
||||||
|
it.performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(testSetup.getRestoreHeight())
|
||||||
|
assertEquals(testSetup.getStage(), RestoreStage.Complete)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
fun height_set_invalid_too_small() {
|
||||||
|
val testSetup = newTestSetup(
|
||||||
|
initialStage = RestoreStage.Birthday,
|
||||||
|
initialWordsList = SeedPhraseFixture.new().split
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_birthday_button_restore)).also {
|
||||||
|
it.assertIsNotEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_birthday_hint)).also {
|
||||||
|
it.performTextInput((ZcashNetwork.Mainnet.saplingActivationHeight.value - 1L).toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_birthday_button_restore)).also {
|
||||||
|
it.assertIsNotEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_birthday_button_skip)).also {
|
||||||
|
it.performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(testSetup.getRestoreHeight())
|
||||||
|
assertEquals(testSetup.getStage(), RestoreStage.Complete)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
fun height_set_invalid_non_digit() {
|
||||||
|
val testSetup = newTestSetup(
|
||||||
|
initialStage = RestoreStage.Birthday,
|
||||||
|
initialWordsList = SeedPhraseFixture.new().split
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_birthday_button_restore)).also {
|
||||||
|
it.assertIsNotEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_birthday_hint)).also {
|
||||||
|
it.performTextInput("1.2")
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_birthday_button_restore)).also {
|
||||||
|
it.assertIsNotEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.restore_birthday_button_skip)).also {
|
||||||
|
it.performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(testSetup.getRestoreHeight())
|
||||||
|
assertEquals(testSetup.getStage(), RestoreStage.Complete)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
fun complete_click_take_to_wallet() {
|
||||||
|
val testSetup = newTestSetup(
|
||||||
|
initialStage = RestoreStage.Complete,
|
||||||
|
initialWordsList = 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_from_seed() {
|
||||||
|
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 back_from_birthday() {
|
||||||
|
val testSetup = newTestSetup(
|
||||||
|
initialStage = RestoreStage.Birthday,
|
||||||
|
initialWordsList = SeedPhraseFixture.new().split
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(0, testSetup.getOnBackCount())
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.restore_back_content_description)).also {
|
||||||
|
it.performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(testSetup.getStage(), RestoreStage.Seed)
|
||||||
|
assertEquals(0, testSetup.getOnBackCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
fun back_from_complete() {
|
||||||
|
val testSetup = newTestSetup(
|
||||||
|
initialStage = RestoreStage.Complete,
|
||||||
|
initialWordsList = SeedPhraseFixture.new().split
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(0, testSetup.getOnBackCount())
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.restore_back_content_description)).also {
|
||||||
|
it.performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(testSetup.getStage(), RestoreStage.Birthday)
|
||||||
|
assertEquals(0, testSetup.getOnBackCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newTestSetup(
|
||||||
|
initialStage: RestoreStage = RestoreStage.Seed,
|
||||||
|
initialWordsList: List<String> = emptyList()
|
||||||
|
) = TestSetup(composeTestRule, initialStage, initialWordsList)
|
||||||
|
|
||||||
|
internal class TestSetup(
|
||||||
|
private val composeTestRule: ComposeContentTestRule,
|
||||||
|
initialStage: RestoreStage,
|
||||||
|
initialWordsList: List<String>
|
||||||
|
) {
|
||||||
|
private val state = RestoreState(initialStage)
|
||||||
|
|
||||||
|
private val wordList = WordList(initialWordsList)
|
||||||
|
|
||||||
private val onBackCount = AtomicInteger(0)
|
private val onBackCount = AtomicInteger(0)
|
||||||
|
|
||||||
private val onFinishedCount = AtomicInteger(0)
|
private val onFinishedCount = AtomicInteger(0)
|
||||||
|
|
||||||
|
private val restoreHeight = MutableStateFlow<BlockHeight?>(null)
|
||||||
|
|
||||||
fun getUserInputWords(): List<String> {
|
fun getUserInputWords(): List<String> {
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
return wordList.current.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getStage(): RestoreStage {
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
return state.current.value
|
return state.current.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getRestoreHeight(): BlockHeight? {
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
return restoreHeight.value
|
||||||
|
}
|
||||||
|
|
||||||
fun getOnBackCount(): Int {
|
fun getOnBackCount(): Int {
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
return onBackCount.get()
|
return onBackCount.get()
|
||||||
|
@ -220,8 +387,14 @@ class RestoreViewTest : UiTestPrerequisites() {
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
ZcashTheme {
|
ZcashTheme {
|
||||||
RestoreWallet(
|
RestoreWallet(
|
||||||
Mnemonics.getCachedWords(Locale.ENGLISH.language).toPersistentSet(),
|
ZcashNetwork.Mainnet,
|
||||||
state,
|
state,
|
||||||
|
Mnemonics.getCachedWords(Locale.ENGLISH.language).toPersistentSet(),
|
||||||
|
wordList,
|
||||||
|
restoreHeight = restoreHeight.collectAsState().value,
|
||||||
|
setRestoreHeight = {
|
||||||
|
restoreHeight.value = it
|
||||||
|
},
|
||||||
onBack = {
|
onBack = {
|
||||||
onBackCount.incrementAndGet()
|
onBackCount.incrementAndGet()
|
||||||
},
|
},
|
||||||
|
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
package co.electriccoin.zcash.ui.screen.onboarding
|
package co.electriccoin.zcash.ui.screen.onboarding
|
||||||
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import cash.z.ecc.android.sdk.model.BlockHeight
|
||||||
import cash.z.ecc.android.sdk.model.PersistableWallet
|
import cash.z.ecc.android.sdk.model.PersistableWallet
|
||||||
import cash.z.ecc.android.sdk.model.SeedPhrase
|
import cash.z.ecc.android.sdk.model.SeedPhrase
|
||||||
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||||
|
@ -24,15 +24,14 @@ import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
|
||||||
import co.electriccoin.zcash.ui.screen.onboarding.view.LongOnboarding
|
import co.electriccoin.zcash.ui.screen.onboarding.view.LongOnboarding
|
||||||
import co.electriccoin.zcash.ui.screen.onboarding.view.ShortOnboarding
|
import co.electriccoin.zcash.ui.screen.onboarding.view.ShortOnboarding
|
||||||
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
|
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
|
||||||
import co.electriccoin.zcash.ui.screen.restore.view.RestoreWallet
|
import co.electriccoin.zcash.ui.screen.restore.WrapRestore
|
||||||
import co.electriccoin.zcash.ui.screen.restore.viewmodel.CompleteWordSetState
|
|
||||||
import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MainActivity.WrapOnboarding() {
|
internal fun MainActivity.WrapOnboarding() {
|
||||||
WrapOnboarding(this)
|
WrapOnboarding(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongMethod")
|
||||||
@Composable
|
@Composable
|
||||||
internal fun WrapOnboarding(
|
internal fun WrapOnboarding(
|
||||||
activity: ComponentActivity
|
activity: ComponentActivity
|
||||||
|
@ -55,7 +54,8 @@ internal fun WrapOnboarding(
|
||||||
persistExistingWalletWithSeedPhrase(
|
persistExistingWalletWithSeedPhrase(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
walletViewModel,
|
walletViewModel,
|
||||||
SeedPhraseFixture.new()
|
SeedPhraseFixture.new(),
|
||||||
|
birthday = null
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
walletViewModel.persistNewWallet()
|
walletViewModel.persistNewWallet()
|
||||||
|
@ -71,7 +71,8 @@ internal fun WrapOnboarding(
|
||||||
persistExistingWalletWithSeedPhrase(
|
persistExistingWalletWithSeedPhrase(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
walletViewModel,
|
walletViewModel,
|
||||||
SeedPhraseFixture.new()
|
SeedPhraseFixture.new(),
|
||||||
|
birthday = null
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
onboardingViewModel.setIsImporting(true)
|
onboardingViewModel.setIsImporting(true)
|
||||||
|
@ -82,7 +83,8 @@ internal fun WrapOnboarding(
|
||||||
persistExistingWalletWithSeedPhrase(
|
persistExistingWalletWithSeedPhrase(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
walletViewModel,
|
walletViewModel,
|
||||||
SeedPhraseFixture.new()
|
SeedPhraseFixture.new(),
|
||||||
|
birthday = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,45 +111,6 @@ internal fun WrapOnboarding(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun WrapRestore(activity: ComponentActivity) {
|
|
||||||
val walletViewModel by activity.viewModels<WalletViewModel>()
|
|
||||||
val onboardingViewModel by activity.viewModels<OnboardingViewModel>()
|
|
||||||
val restoreViewModel by activity.viewModels<RestoreViewModel>()
|
|
||||||
|
|
||||||
val applicationContext = LocalContext.current.applicationContext
|
|
||||||
|
|
||||||
when (val completeWordList = restoreViewModel.completeWordList.collectAsStateWithLifecycle().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.setIsImporting(false) },
|
|
||||||
paste = {
|
|
||||||
val clipboardManager = applicationContext.getSystemService(ClipboardManager::class.java)
|
|
||||||
return@RestoreWallet clipboardManager?.primaryClip?.toString()
|
|
||||||
},
|
|
||||||
onFinished = {
|
|
||||||
persistExistingWalletWithSeedPhrase(
|
|
||||||
applicationContext,
|
|
||||||
walletViewModel,
|
|
||||||
SeedPhrase(restoreViewModel.userWordList.current.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists existing wallet together with the backup complete flag to disk. Be aware of that, it
|
* Persists existing wallet together with the backup complete flag to disk. Be aware of that, it
|
||||||
* triggers navigation changes, as we observe the WalletViewModel.secretState.
|
* triggers navigation changes, as we observe the WalletViewModel.secretState.
|
||||||
|
@ -155,19 +118,21 @@ private fun WrapRestore(activity: ComponentActivity) {
|
||||||
* Write the backup complete flag first, then the seed phrase. That avoids the UI flickering to
|
* 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.
|
* the backup screen. Assume if a user is restoring from a backup, then the user has a valid backup.
|
||||||
*
|
*
|
||||||
* @param seedPhrase to be persisted along with the wallet object
|
* @param seedPhrase to be persisted as part of the wallet.
|
||||||
|
* @param birthday optional user provided birthday to be persisted as part of the wallet.
|
||||||
*/
|
*/
|
||||||
private fun persistExistingWalletWithSeedPhrase(
|
internal fun persistExistingWalletWithSeedPhrase(
|
||||||
context: Context,
|
context: Context,
|
||||||
walletViewModel: WalletViewModel,
|
walletViewModel: WalletViewModel,
|
||||||
seedPhrase: SeedPhrase
|
seedPhrase: SeedPhrase,
|
||||||
|
birthday: BlockHeight?
|
||||||
) {
|
) {
|
||||||
walletViewModel.persistBackupComplete()
|
walletViewModel.persistBackupComplete()
|
||||||
|
|
||||||
val network = ZcashNetwork.fromResources(context)
|
val network = ZcashNetwork.fromResources(context)
|
||||||
val restoredWallet = PersistableWallet(
|
val restoredWallet = PersistableWallet(
|
||||||
network,
|
network,
|
||||||
null,
|
birthday,
|
||||||
seedPhrase
|
seedPhrase
|
||||||
)
|
)
|
||||||
walletViewModel.persistExistingWallet(restoredWallet)
|
walletViewModel.persistExistingWallet(restoredWallet)
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
package co.electriccoin.zcash.ui.screen.restore
|
||||||
|
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import cash.z.ecc.android.sdk.model.SeedPhrase
|
||||||
|
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||||
|
import cash.z.ecc.sdk.type.fromResources
|
||||||
|
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
|
||||||
|
import co.electriccoin.zcash.ui.screen.onboarding.persistExistingWalletWithSeedPhrase
|
||||||
|
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
|
||||||
|
import co.electriccoin.zcash.ui.screen.restore.view.RestoreWallet
|
||||||
|
import co.electriccoin.zcash.ui.screen.restore.viewmodel.CompleteWordSetState
|
||||||
|
import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WrapRestore(activity: ComponentActivity) {
|
||||||
|
val walletViewModel by activity.viewModels<WalletViewModel>()
|
||||||
|
val onboardingViewModel by activity.viewModels<OnboardingViewModel>()
|
||||||
|
val restoreViewModel by activity.viewModels<RestoreViewModel>()
|
||||||
|
|
||||||
|
val applicationContext = LocalContext.current.applicationContext
|
||||||
|
|
||||||
|
when (val completeWordList = restoreViewModel.completeWordList.collectAsStateWithLifecycle().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(
|
||||||
|
ZcashNetwork.fromResources(applicationContext),
|
||||||
|
restoreViewModel.restoreState,
|
||||||
|
completeWordList.list,
|
||||||
|
restoreViewModel.userWordList,
|
||||||
|
restoreViewModel.userBirthdayHeight.collectAsStateWithLifecycle().value,
|
||||||
|
setRestoreHeight = { restoreViewModel.userBirthdayHeight.value = it },
|
||||||
|
onBack = { onboardingViewModel.setIsImporting(false) },
|
||||||
|
paste = {
|
||||||
|
val clipboardManager = applicationContext.getSystemService(ClipboardManager::class.java)
|
||||||
|
return@RestoreWallet clipboardManager?.primaryClip?.toString()
|
||||||
|
},
|
||||||
|
onFinished = {
|
||||||
|
persistExistingWalletWithSeedPhrase(
|
||||||
|
applicationContext,
|
||||||
|
walletViewModel,
|
||||||
|
SeedPhrase(restoreViewModel.userWordList.current.value),
|
||||||
|
restoreViewModel.userBirthdayHeight.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ package co.electriccoin.zcash.ui.screen.restore
|
||||||
*/
|
*/
|
||||||
object RestoreTag {
|
object RestoreTag {
|
||||||
const val SEED_WORD_TEXT_FIELD = "seed_text_field"
|
const val SEED_WORD_TEXT_FIELD = "seed_text_field"
|
||||||
|
const val BIRTHDAY_TEXT_FIELD = "birthday_text_field"
|
||||||
const val CHIP_LAYOUT = "chip_group"
|
const val CHIP_LAYOUT = "chip_group"
|
||||||
const val AUTOCOMPLETE_LAYOUT = "autocomplete_layout"
|
const val AUTOCOMPLETE_LAYOUT = "autocomplete_layout"
|
||||||
const val AUTOCOMPLETE_ITEM = "autocomplete_item"
|
const val AUTOCOMPLETE_ITEM = "autocomplete_item"
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
package co.electriccoin.zcash.ui.screen.restore.model
|
||||||
|
|
||||||
|
enum class RestoreStage {
|
||||||
|
// Note: the ordinal order is used to manage progression through each stage
|
||||||
|
// so be careful if reordering these
|
||||||
|
Seed,
|
||||||
|
Birthday,
|
||||||
|
Complete;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see getPrevious
|
||||||
|
*/
|
||||||
|
fun hasPrevious() = ordinal > 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see getNext
|
||||||
|
*/
|
||||||
|
fun hasNext() = ordinal < values().size - 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Previous item in ordinal order. Returns the first item when it cannot go further back.
|
||||||
|
*/
|
||||||
|
fun getPrevious() = values()[maxOf(0, ordinal - 1)]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Last item in ordinal order. Returns the last item when it cannot go further forward.
|
||||||
|
*/
|
||||||
|
fun getNext() = values()[minOf(values().size - 1, ordinal + 1)]
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
package co.electriccoin.zcash.ui.screen.restore.state
|
||||||
|
|
||||||
|
import co.electriccoin.zcash.ui.screen.restore.model.RestoreStage
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param initialState Allows restoring the state from a different starting point. This is
|
||||||
|
* primarily useful on Android, for automated tests, and for iterative debugging with the Compose
|
||||||
|
* layout preview. The default constructor argument is generally fine for other platforms.
|
||||||
|
*/
|
||||||
|
class RestoreState(initialState: RestoreStage = RestoreStage.values().first()) {
|
||||||
|
|
||||||
|
private val mutableState = MutableStateFlow(initialState)
|
||||||
|
|
||||||
|
val current: StateFlow<RestoreStage> = mutableState
|
||||||
|
|
||||||
|
fun goNext() {
|
||||||
|
mutableState.value = current.value.getNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun goPrevious() {
|
||||||
|
mutableState.value = current.value.getPrevious()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
package co.electriccoin.zcash.ui.screen.restore.state
|
package co.electriccoin.zcash.ui.screen.restore.state
|
||||||
|
|
||||||
|
import cash.z.ecc.android.sdk.model.SeedPhrase
|
||||||
import cash.z.ecc.sdk.model.SeedPhraseValidation
|
import cash.z.ecc.sdk.model.SeedPhraseValidation
|
||||||
|
import co.electriccoin.zcash.ui.common.first
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
@ -17,7 +19,11 @@ class WordList(initial: List<String> = emptyList()) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun append(words: List<String>) {
|
fun append(words: List<String>) {
|
||||||
mutableState.value = (current.value + words).toPersistentList()
|
val newList = (current.value + words)
|
||||||
|
.first(SeedPhrase.SEED_PHRASE_SIZE) // Prevent pasting too many words
|
||||||
|
.toPersistentList()
|
||||||
|
|
||||||
|
mutableState.value = newList
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom toString to prevent leaking word list
|
// Custom toString to prevent leaking word list
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
|
@file:Suppress("TooManyFunctions")
|
||||||
|
|
||||||
package co.electriccoin.zcash.ui.screen.restore.view
|
package co.electriccoin.zcash.ui.screen.restore.view
|
||||||
|
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
@ -53,6 +54,8 @@ import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import cash.z.ecc.android.sdk.model.BlockHeight
|
||||||
|
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||||
import cash.z.ecc.sdk.model.SeedPhraseValidation
|
import cash.z.ecc.sdk.model.SeedPhraseValidation
|
||||||
import co.electriccoin.zcash.spackle.model.Index
|
import co.electriccoin.zcash.spackle.model.Index
|
||||||
import co.electriccoin.zcash.ui.R
|
import co.electriccoin.zcash.ui.R
|
||||||
|
@ -65,9 +68,13 @@ import co.electriccoin.zcash.ui.design.component.GradientSurface
|
||||||
import co.electriccoin.zcash.ui.design.component.Header
|
import co.electriccoin.zcash.ui.design.component.Header
|
||||||
import co.electriccoin.zcash.ui.design.component.NavigationButton
|
import co.electriccoin.zcash.ui.design.component.NavigationButton
|
||||||
import co.electriccoin.zcash.ui.design.component.PrimaryButton
|
import co.electriccoin.zcash.ui.design.component.PrimaryButton
|
||||||
|
import co.electriccoin.zcash.ui.design.component.TertiaryButton
|
||||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||||
|
import co.electriccoin.zcash.ui.design.theme.ZcashTheme.dimens
|
||||||
import co.electriccoin.zcash.ui.screen.restore.RestoreTag
|
import co.electriccoin.zcash.ui.screen.restore.RestoreTag
|
||||||
import co.electriccoin.zcash.ui.screen.restore.model.ParseResult
|
import co.electriccoin.zcash.ui.screen.restore.model.ParseResult
|
||||||
|
import co.electriccoin.zcash.ui.screen.restore.model.RestoreStage
|
||||||
|
import co.electriccoin.zcash.ui.screen.restore.state.RestoreState
|
||||||
import co.electriccoin.zcash.ui.screen.restore.state.WordList
|
import co.electriccoin.zcash.ui.screen.restore.state.WordList
|
||||||
import co.electriccoin.zcash.ui.screen.restore.state.wordValidation
|
import co.electriccoin.zcash.ui.screen.restore.state.wordValidation
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
@ -81,6 +88,8 @@ fun PreviewRestore() {
|
||||||
ZcashTheme(darkTheme = true) {
|
ZcashTheme(darkTheme = true) {
|
||||||
GradientSurface {
|
GradientSurface {
|
||||||
RestoreWallet(
|
RestoreWallet(
|
||||||
|
ZcashNetwork.Mainnet,
|
||||||
|
restoreState = RestoreState(RestoreStage.Seed),
|
||||||
completeWordList = persistentHashSetOf(
|
completeWordList = persistentHashSetOf(
|
||||||
"abandon",
|
"abandon",
|
||||||
"ability",
|
"ability",
|
||||||
|
@ -94,6 +103,8 @@ fun PreviewRestore() {
|
||||||
"ribbon"
|
"ribbon"
|
||||||
),
|
),
|
||||||
userWordList = WordList(listOf("abandon", "absorb")),
|
userWordList = WordList(listOf("abandon", "absorb")),
|
||||||
|
restoreHeight = null,
|
||||||
|
setRestoreHeight = {},
|
||||||
onBack = {},
|
onBack = {},
|
||||||
paste = { "" },
|
paste = { "" },
|
||||||
onFinished = {}
|
onFinished = {}
|
||||||
|
@ -113,11 +124,21 @@ fun PreviewRestoreComplete() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO [#409]: https://github.com/zcash/secant-android-wallet/issues/409
|
// TODO [#409]: https://github.com/zcash/secant-android-wallet/issues/409
|
||||||
|
/**
|
||||||
|
* Note that the restore review doesn't allow the user to go back once the seed is entered correctly.
|
||||||
|
*
|
||||||
|
* @param restoreHeight A null height indicates no user input.
|
||||||
|
*/
|
||||||
|
@Suppress("LongParameterList", "LongMethod")
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RestoreWallet(
|
fun RestoreWallet(
|
||||||
|
zcashNetwork: ZcashNetwork,
|
||||||
|
restoreState: RestoreState,
|
||||||
completeWordList: ImmutableSet<String>,
|
completeWordList: ImmutableSet<String>,
|
||||||
userWordList: WordList,
|
userWordList: WordList,
|
||||||
|
restoreHeight: BlockHeight?,
|
||||||
|
setRestoreHeight: (BlockHeight?) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
paste: () -> String?,
|
paste: () -> String?,
|
||||||
onFinished: () -> Unit
|
onFinished: () -> Unit
|
||||||
|
@ -126,49 +147,92 @@ fun RestoreWallet(
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val parseResult = ParseResult.new(completeWordList, textState)
|
val parseResult = ParseResult.new(completeWordList, textState)
|
||||||
|
|
||||||
SecureScreen()
|
val currentStage = restoreState.current.collectAsStateWithLifecycle().value
|
||||||
userWordList.wordValidation().collectAsState(null).value?.let { seedPhraseValidation ->
|
|
||||||
if (seedPhraseValidation !is SeedPhraseValidation.Valid) {
|
Scaffold(
|
||||||
Scaffold(topBar = {
|
topBar = {
|
||||||
RestoreTopAppBar(onBack = onBack, onClear = { userWordList.set(emptyList()) })
|
RestoreTopAppBar(
|
||||||
}, bottomBar = {
|
onBack = {
|
||||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
if (currentStage.hasPrevious()) {
|
||||||
Warn(parseResult)
|
restoreState.goPrevious()
|
||||||
Autocomplete(parseResult = parseResult, {
|
} else {
|
||||||
textState = ""
|
onBack()
|
||||||
userWordList.append(listOf(it))
|
}
|
||||||
focusRequester.requestFocus()
|
},
|
||||||
})
|
isShowClear = currentStage == RestoreStage.Seed,
|
||||||
NextWordTextField(
|
onClear = { userWordList.set(emptyList()) }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
when (currentStage) {
|
||||||
|
RestoreStage.Seed -> {
|
||||||
|
RestoreSeedBottomBar(
|
||||||
|
userWordList = userWordList,
|
||||||
parseResult = parseResult,
|
parseResult = parseResult,
|
||||||
text = textState,
|
setTextState = { textState = it },
|
||||||
setText = { textState = it },
|
focusRequester = focusRequester
|
||||||
modifier = Modifier.focusRequester(focusRequester)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}) { paddingValues ->
|
RestoreStage.Birthday -> {
|
||||||
RestoreMainContent(
|
// No content
|
||||||
paddingValues = paddingValues,
|
}
|
||||||
|
RestoreStage.Complete -> {
|
||||||
|
// No content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content = { paddingValues ->
|
||||||
|
when (currentStage) {
|
||||||
|
RestoreStage.Seed -> {
|
||||||
|
SecureScreen()
|
||||||
|
|
||||||
|
RestoreSeedMainContent(
|
||||||
userWordList = userWordList,
|
userWordList = userWordList,
|
||||||
onTextStateChange = { textState = it },
|
textState = textState,
|
||||||
|
setTextState = { textState = it },
|
||||||
focusRequester = focusRequester,
|
focusRequester = focusRequester,
|
||||||
parseResult = parseResult,
|
parseResult = parseResult,
|
||||||
paste = paste
|
paste = paste,
|
||||||
|
goNext = { restoreState.goNext() },
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
top = paddingValues.calculateTopPadding(),
|
||||||
|
bottom = paddingValues.calculateBottomPadding()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
RestoreStage.Birthday -> {
|
||||||
|
RestoreBirthday(
|
||||||
|
zcashNetwork = zcashNetwork,
|
||||||
|
initialRestoreHeight = restoreHeight,
|
||||||
|
setRestoreHeight = setRestoreHeight,
|
||||||
|
onNext = { restoreState.goNext() },
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
top = paddingValues.calculateTopPadding(),
|
||||||
|
bottom = paddingValues.calculateBottomPadding()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
RestoreStage.Complete -> {
|
||||||
// In some cases we need to hide the software keyboard manually, as it stays shown after
|
// In some cases we need to hide the software keyboard manually, as it stays shown after
|
||||||
// all words are filled successfully.
|
// input on prior screens
|
||||||
LocalSoftwareKeyboardController.current?.hide()
|
LocalSoftwareKeyboardController.current?.hide()
|
||||||
|
|
||||||
RestoreComplete(onComplete = onFinished)
|
RestoreComplete(
|
||||||
|
onComplete = onFinished,
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
top = paddingValues.calculateTopPadding(),
|
||||||
|
bottom = paddingValues.calculateBottomPadding()
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
private fun RestoreTopAppBar(onBack: () -> Unit, onClear: () -> Unit) {
|
private fun RestoreTopAppBar(onBack: () -> Unit, isShowClear: Boolean, onClear: () -> Unit) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(text = stringResource(id = R.string.restore_title)) },
|
title = { Text(text = stringResource(id = R.string.restore_title)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
|
@ -182,54 +246,78 @@ private fun RestoreTopAppBar(onBack: () -> Unit, onClear: () -> Unit) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
if (isShowClear) {
|
||||||
NavigationButton(onClick = onClear, stringResource(R.string.restore_button_clear))
|
NavigationButton(onClick = onClear, stringResource(R.string.restore_button_clear))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO [#672] Implement custom seed phrase pasting for wallet import
|
// TODO [#672] Implement custom seed phrase pasting for wallet import
|
||||||
// TODO [#672] https://github.com/zcash/secant-android-wallet/issues/672
|
// TODO [#672] https://github.com/zcash/secant-android-wallet/issues/672
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Suppress("UNUSED_PARAMETER", "LongParameterList")
|
@Suppress("UNUSED_PARAMETER", "LongParameterList")
|
||||||
@Composable
|
@Composable
|
||||||
private fun RestoreMainContent(
|
private fun RestoreSeedMainContent(
|
||||||
paddingValues: PaddingValues,
|
|
||||||
userWordList: WordList,
|
userWordList: WordList,
|
||||||
onTextStateChange: (String) -> Unit,
|
textState: String,
|
||||||
|
setTextState: (String) -> Unit,
|
||||||
focusRequester: FocusRequester,
|
focusRequester: FocusRequester,
|
||||||
parseResult: ParseResult,
|
parseResult: ParseResult,
|
||||||
paste: () -> String?
|
paste: () -> String?,
|
||||||
|
goNext: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val currentUserWordList = userWordList.current.collectAsStateWithLifecycle().value
|
val currentUserWordList = userWordList.current.collectAsStateWithLifecycle().value
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
if (parseResult is ParseResult.Add) {
|
if (parseResult is ParseResult.Add) {
|
||||||
onTextStateChange("")
|
setTextState("")
|
||||||
userWordList.append(parseResult.words)
|
userWordList.append(parseResult.words)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isSeedValid = userWordList.wordValidation().collectAsState(null).value is SeedPhraseValidation.Valid
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
modifier.then(Modifier.verticalScroll(scrollState))
|
||||||
.fillMaxWidth()
|
|
||||||
.fillMaxHeight()
|
|
||||||
.verticalScroll(scrollState)
|
|
||||||
.padding(
|
|
||||||
top = paddingValues.calculateTopPadding(),
|
|
||||||
bottom = paddingValues.calculateBottomPadding()
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Body(
|
Body(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(dimens.spacingDefault),
|
||||||
text = stringResource(id = R.string.restore_instructions)
|
text = stringResource(id = R.string.restore_seed_instructions)
|
||||||
)
|
)
|
||||||
|
|
||||||
ChipGridWithText(currentUserWordList)
|
ChipGridWithText(currentUserWordList)
|
||||||
|
|
||||||
|
if (!isSeedValid) {
|
||||||
|
NextWordTextField(
|
||||||
|
parseResult = parseResult,
|
||||||
|
text = textState,
|
||||||
|
setText = { setTextState(it) },
|
||||||
|
modifier = Modifier.focusRequester(focusRequester)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cause text field to refocus
|
Spacer(modifier = Modifier.weight(MINIMAL_WEIGHT))
|
||||||
|
|
||||||
|
PrimaryButton(
|
||||||
|
onClick = goNext,
|
||||||
|
text = stringResource(id = R.string.restore_seed_button_restore),
|
||||||
|
enabled = isSeedValid
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSeedValid) {
|
||||||
|
// Hides the keyboard, making it easier for users to see the next button
|
||||||
|
LocalSoftwareKeyboardController.current?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cause text field to refocus
|
||||||
DisposableEffect(parseResult) {
|
DisposableEffect(parseResult) {
|
||||||
|
if (!isSeedValid) {
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
scrollState.scrollTo(scrollState.maxValue)
|
scrollState.scrollTo(scrollState.maxValue)
|
||||||
}
|
}
|
||||||
|
@ -237,6 +325,29 @@ private fun RestoreMainContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RestoreSeedBottomBar(
|
||||||
|
userWordList: WordList,
|
||||||
|
parseResult: ParseResult,
|
||||||
|
setTextState: (String) -> Unit,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val isSeedValid = userWordList.wordValidation().collectAsState(null).value is SeedPhraseValidation.Valid
|
||||||
|
// Hide the field once the user has completed the seed phrase; if they need the field back then
|
||||||
|
// the user can hit the clear button
|
||||||
|
if (!isSeedValid) {
|
||||||
|
Column(modifier) {
|
||||||
|
Warn(parseResult)
|
||||||
|
Autocomplete(parseResult = parseResult, {
|
||||||
|
setTextState("")
|
||||||
|
userWordList.append(listOf(it))
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChipGridWithText(
|
private fun ChipGridWithText(
|
||||||
userWordList: ImmutableList<String>
|
userWordList: ImmutableList<String>
|
||||||
|
@ -276,21 +387,22 @@ private fun NextWordTextField(
|
||||||
setText: (String) -> Unit,
|
setText: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
/*
|
|
||||||
* Treat the user input as a password, but disable the transformation to obscure input.
|
|
||||||
*/
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(4.dp),
|
.padding(dimens.spacingTiny),
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
shadowElevation = 8.dp
|
shadowElevation = 8.dp
|
||||||
) {
|
) {
|
||||||
|
/*
|
||||||
|
* Treat the user input as a password for more secure input, but disable the transformation
|
||||||
|
* to obscure typing.
|
||||||
|
*/
|
||||||
TextField(
|
TextField(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(4.dp)
|
.padding(dimens.spacingTiny)
|
||||||
.testTag(RestoreTag.SEED_WORD_TEXT_FIELD),
|
.testTag(RestoreTag.SEED_WORD_TEXT_FIELD),
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = setText,
|
onValueChange = setText,
|
||||||
|
@ -359,7 +471,7 @@ private fun Warn(parseResult: ParseResult) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(4.dp),
|
.padding(dimens.spacingTiny),
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
shadowElevation = 4.dp
|
shadowElevation = 4.dp
|
||||||
|
@ -367,21 +479,85 @@ private fun Warn(parseResult: ParseResult) {
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(4.dp),
|
.padding(dimens.spacingTiny),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
text = if (parseResult.suggestions.isEmpty()) {
|
text = if (parseResult.suggestions.isEmpty()) {
|
||||||
stringResource(id = R.string.restore_warning_no_suggestions)
|
stringResource(id = R.string.restore_seed_warning_no_suggestions)
|
||||||
} else {
|
} else {
|
||||||
stringResource(id = R.string.restore_warning_suggestions)
|
stringResource(id = R.string.restore_seed_warning_suggestions)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun RestoreComplete(onComplete: () -> Unit) {
|
private fun RestoreBirthday(
|
||||||
Column {
|
zcashNetwork: ZcashNetwork,
|
||||||
|
initialRestoreHeight: BlockHeight?,
|
||||||
|
setRestoreHeight: (BlockHeight?) -> Unit,
|
||||||
|
onNext: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val (height, setHeight) = rememberSaveable {
|
||||||
|
mutableStateOf(initialRestoreHeight?.value?.toString() ?: "")
|
||||||
|
}
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
Column(modifier.verticalScroll(scrollState)) {
|
||||||
|
Header(stringResource(R.string.restore_birthday_header))
|
||||||
|
Body(stringResource(R.string.restore_birthday_body))
|
||||||
|
TextField(
|
||||||
|
value = height,
|
||||||
|
onValueChange = { heightString ->
|
||||||
|
val filteredHeightString = heightString.filter { it.isDigit() }
|
||||||
|
setHeight(filteredHeightString)
|
||||||
|
},
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(dimens.spacingTiny)
|
||||||
|
.testTag(RestoreTag.BIRTHDAY_TEXT_FIELD),
|
||||||
|
label = { Text(stringResource(id = R.string.restore_birthday_hint)) },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
KeyboardCapitalization.None,
|
||||||
|
autoCorrect = false,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
keyboardType = KeyboardType.Number
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onAny = {}),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
)
|
||||||
|
Spacer(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(MINIMAL_WEIGHT)
|
||||||
|
)
|
||||||
|
|
||||||
|
val isBirthdayValid = height.toLongOrNull()?.let {
|
||||||
|
it >= zcashNetwork.saplingActivationHeight.value
|
||||||
|
} ?: false
|
||||||
|
|
||||||
|
PrimaryButton(
|
||||||
|
onClick = {
|
||||||
|
setRestoreHeight(BlockHeight.new(zcashNetwork, height.toLong()))
|
||||||
|
onNext()
|
||||||
|
},
|
||||||
|
text = stringResource(R.string.restore_birthday_button_restore),
|
||||||
|
enabled = isBirthdayValid
|
||||||
|
)
|
||||||
|
TertiaryButton(
|
||||||
|
onClick = {
|
||||||
|
setRestoreHeight(null)
|
||||||
|
onNext()
|
||||||
|
},
|
||||||
|
text = stringResource(R.string.restore_birthday_button_skip)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RestoreComplete(onComplete: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier) {
|
||||||
Header(stringResource(R.string.restore_complete_header))
|
Header(stringResource(R.string.restore_complete_header))
|
||||||
Body(stringResource(R.string.restore_complete_info))
|
Body(stringResource(R.string.restore_complete_info))
|
||||||
Spacer(
|
Spacer(
|
||||||
|
@ -390,6 +566,5 @@ private fun RestoreComplete(onComplete: () -> Unit) {
|
||||||
.weight(MINIMAL_WEIGHT)
|
.weight(MINIMAL_WEIGHT)
|
||||||
)
|
)
|
||||||
PrimaryButton(onComplete, stringResource(R.string.restore_button_see_wallet))
|
PrimaryButton(onComplete, stringResource(R.string.restore_button_see_wallet))
|
||||||
// TODO [#151]: Add option to provide wallet birthday
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,11 +6,17 @@ import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import cash.z.ecc.android.bip39.Mnemonics
|
import cash.z.ecc.android.bip39.Mnemonics
|
||||||
import cash.z.ecc.android.sdk.ext.collectWith
|
import cash.z.ecc.android.sdk.ext.collectWith
|
||||||
|
import cash.z.ecc.android.sdk.model.BlockHeight
|
||||||
|
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||||
|
import cash.z.ecc.sdk.type.fromResources
|
||||||
import co.electriccoin.zcash.ui.common.ANDROID_STATE_FLOW_TIMEOUT
|
import co.electriccoin.zcash.ui.common.ANDROID_STATE_FLOW_TIMEOUT
|
||||||
|
import co.electriccoin.zcash.ui.screen.restore.model.RestoreStage
|
||||||
|
import co.electriccoin.zcash.ui.screen.restore.state.RestoreState
|
||||||
import co.electriccoin.zcash.ui.screen.restore.state.WordList
|
import co.electriccoin.zcash.ui.screen.restore.state.WordList
|
||||||
import kotlinx.collections.immutable.ImmutableSet
|
import kotlinx.collections.immutable.ImmutableSet
|
||||||
import kotlinx.collections.immutable.toPersistentSet
|
import kotlinx.collections.immutable.toPersistentSet
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.WhileSubscribed
|
import kotlinx.coroutines.flow.WhileSubscribed
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
@ -20,6 +26,20 @@ import java.util.Locale
|
||||||
|
|
||||||
class RestoreViewModel(application: Application, savedStateHandle: SavedStateHandle) : AndroidViewModel(application) {
|
class RestoreViewModel(application: Application, savedStateHandle: SavedStateHandle) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
val restoreState: RestoreState = run {
|
||||||
|
val initialValue = if (savedStateHandle.contains(KEY_STAGE)) {
|
||||||
|
savedStateHandle.get<RestoreStage>(KEY_STAGE)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null == initialValue) {
|
||||||
|
RestoreState()
|
||||||
|
} else {
|
||||||
|
RestoreState(initialValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The complete word list that the user can choose from; useful for autocomplete
|
* The complete word list that the user can choose from; useful for autocomplete
|
||||||
*/
|
*/
|
||||||
|
@ -51,16 +71,31 @@ class RestoreViewModel(application: Application, savedStateHandle: SavedStateHan
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val userBirthdayHeight: MutableStateFlow<BlockHeight?> = run {
|
||||||
|
val initialValue: BlockHeight? = savedStateHandle.get<Long>(KEY_BIRTHDAY_HEIGHT)?.let {
|
||||||
|
BlockHeight.new(ZcashNetwork.fromResources(application), it)
|
||||||
|
}
|
||||||
|
MutableStateFlow(initialValue)
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// viewModelScope is constructed with Dispatchers.Main.immediate, so this will
|
// viewModelScope is constructed with Dispatchers.Main.immediate, so this will
|
||||||
// update the save state as soon as a change occurs.
|
// update the save state as soon as a change occurs.
|
||||||
userWordList.current.collectWith(viewModelScope) {
|
userWordList.current.collectWith(viewModelScope) {
|
||||||
savedStateHandle[KEY_WORD_LIST] = ArrayList(it)
|
savedStateHandle[KEY_WORD_LIST] = ArrayList(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userBirthdayHeight.collectWith(viewModelScope) {
|
||||||
|
savedStateHandle[KEY_BIRTHDAY_HEIGHT] = it?.value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val KEY_STAGE = "stage" // $NON-NLS
|
||||||
|
|
||||||
private const val KEY_WORD_LIST = "word_list" // $NON-NLS
|
private const val KEY_WORD_LIST = "word_list" // $NON-NLS
|
||||||
|
|
||||||
|
private const val KEY_BIRTHDAY_HEIGHT = "birthday_height" // $NON-NLS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,15 +2,21 @@
|
||||||
<string name="restore_header">Create a wallet</string>
|
<string name="restore_header">Create a wallet</string>
|
||||||
<string name="restore_title">Wallet import</string>
|
<string name="restore_title">Wallet import</string>
|
||||||
<string name="restore_back_content_description">Back</string>
|
<string name="restore_back_content_description">Back</string>
|
||||||
<string name="restore_button_clear">Clear</string>
|
|
||||||
<string name="restore_instructions">You can import your backed up wallet by entering your backup recovery phrase (aka seed phrase) now.</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_button_clear">Clear</string>
|
||||||
<string name="restore_warning_no_suggestions">This word is not in the seed phrase dictionary.</string>
|
<string name="restore_seed_instructions">You can import your backed up wallet by entering your backup recovery phrase (aka seed phrase) now.</string>
|
||||||
|
<string name="restore_seed_button_restore">Restore wallet</string>
|
||||||
|
<string name="restore_seed_warning_suggestions">This word is not in the seed phrase dictionary. Please select the correct one from the suggestions.</string>
|
||||||
|
<string name="restore_seed_warning_no_suggestions">This word is not in the seed phrase dictionary.</string>
|
||||||
|
|
||||||
|
<string name="restore_birthday_header">Do you know the wallet’s birthday?</string>
|
||||||
|
<string name="restore_birthday_body">This will allow a faster sync. If you don’t know the wallet’s birthday, don’t worry.</string>
|
||||||
|
<string name="restore_birthday_hint">Birthday height</string>
|
||||||
|
<string name="restore_birthday_button_restore">Restore with this birthday</string>
|
||||||
|
<string name="restore_birthday_button_skip">I don’t know the birthday</string>
|
||||||
|
|
||||||
<string name="restore_complete_header">Seed phrase imported!</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_complete_info">We will now scan the blockchain to find your transactions and balance.</string>
|
||||||
<string name="restore_button_see_wallet">Take me to my wallet</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>
|
</resources>
|
||||||
|
|
|
@ -5,7 +5,9 @@ package co.electroniccoin.zcash.ui.screenshot
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.LocaleList
|
import android.os.LocaleList
|
||||||
|
import androidx.activity.viewModels
|
||||||
import androidx.compose.ui.test.assertCountEquals
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
|
import androidx.compose.ui.test.assertIsEnabled
|
||||||
import androidx.compose.ui.test.hasContentDescription
|
import androidx.compose.ui.test.hasContentDescription
|
||||||
import androidx.compose.ui.test.hasTestTag
|
import androidx.compose.ui.test.hasTestTag
|
||||||
import androidx.compose.ui.test.hasText
|
import androidx.compose.ui.test.hasText
|
||||||
|
@ -30,6 +32,7 @@ import androidx.test.filters.MediumTest
|
||||||
import androidx.test.filters.SdkSuppress
|
import androidx.test.filters.SdkSuppress
|
||||||
import cash.z.ecc.android.sdk.fixture.WalletAddressFixture
|
import cash.z.ecc.android.sdk.fixture.WalletAddressFixture
|
||||||
import cash.z.ecc.android.sdk.model.MonetarySeparators
|
import cash.z.ecc.android.sdk.model.MonetarySeparators
|
||||||
|
import cash.z.ecc.android.sdk.model.SeedPhrase
|
||||||
import cash.z.ecc.sdk.fixture.SeedPhraseFixture
|
import cash.z.ecc.sdk.fixture.SeedPhraseFixture
|
||||||
import co.electriccoin.zcash.configuration.model.map.StringConfiguration
|
import co.electriccoin.zcash.configuration.model.map.StringConfiguration
|
||||||
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
|
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
|
||||||
|
@ -43,12 +46,14 @@ import co.electriccoin.zcash.ui.design.component.UiMode
|
||||||
import co.electriccoin.zcash.ui.screen.backup.BackupTag
|
import co.electriccoin.zcash.ui.screen.backup.BackupTag
|
||||||
import co.electriccoin.zcash.ui.screen.home.viewmodel.SecretState
|
import co.electriccoin.zcash.ui.screen.home.viewmodel.SecretState
|
||||||
import co.electriccoin.zcash.ui.screen.restore.RestoreTag
|
import co.electriccoin.zcash.ui.screen.restore.RestoreTag
|
||||||
|
import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel
|
||||||
import kotlinx.collections.immutable.persistentMapOf
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
private const val DEFAULT_TIMEOUT_MILLISECONDS = 10_000L
|
private const val DEFAULT_TIMEOUT_MILLISECONDS = 10_000L
|
||||||
|
|
||||||
|
@ -137,6 +142,7 @@ class ScreenshotTest : UiTestPrerequisites() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongMethod", "FunctionNaming")
|
||||||
private fun take_screenshots_for_restore_wallet(resContext: Context, tag: String) {
|
private fun take_screenshots_for_restore_wallet(resContext: Context, tag: String) {
|
||||||
// TODO [#286]: Screenshot tests fail on Firebase Test Lab
|
// TODO [#286]: Screenshot tests fail on Firebase Test Lab
|
||||||
// TODO [#286]: https://github.com/zcash/secant-android-wallet/issues/286
|
// TODO [#286]: https://github.com/zcash/secant-android-wallet/issues/286
|
||||||
|
@ -185,11 +191,39 @@ class ScreenshotTest : UiTestPrerequisites() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composeTestRule.waitUntil {
|
||||||
|
composeTestRule.activity.viewModels<RestoreViewModel>().value.userWordList.current.value.size == SeedPhrase.SEED_PHRASE_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(resContext.getString(R.string.restore_seed_button_restore)).also {
|
||||||
|
it.performScrollTo()
|
||||||
|
|
||||||
|
// Even with waiting for the word list in the view model, there's some latency before the button is enabled
|
||||||
|
composeTestRule.waitUntil(5.seconds.inWholeMilliseconds) {
|
||||||
|
kotlin.runCatching { it.assertIsEnabled() }.isSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
it.performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(resContext.getString(R.string.restore_birthday_button_restore)).also {
|
||||||
|
composeTestRule.waitUntil(5.seconds.inWholeMilliseconds) {
|
||||||
|
kotlin.runCatching { it.assertExists() }.isSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
takeScreenshot(tag, "Import 3")
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(resContext.getString(R.string.restore_birthday_button_skip)).also {
|
||||||
|
it.performScrollTo()
|
||||||
|
it.performClick()
|
||||||
|
}
|
||||||
|
|
||||||
composeTestRule.onNodeWithText(resContext.getString(R.string.restore_complete_header)).also {
|
composeTestRule.onNodeWithText(resContext.getString(R.string.restore_complete_header)).also {
|
||||||
it.assertExists()
|
it.assertExists()
|
||||||
}
|
}
|
||||||
|
|
||||||
takeScreenshot(tag, "Import 3")
|
takeScreenshot(tag, "Import 4")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
Loading…
Reference in New Issue