[#151] Add birthday to restore

This commit is contained in:
Carter Jernigan 2023-03-21 15:04:16 -04:00 committed by GitHub
parent 0c0bf8cb34
commit 84b40fc5fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 834 additions and 193 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, }
userWordList = userWordList, RestoreStage.Complete -> {
onTextStateChange = { textState = it }, // No content
focusRequester = focusRequester, }
parseResult = parseResult,
paste = paste
)
} }
} else { },
// In some cases we need to hide the software keyboard manually, as it stays shown after content = { paddingValues ->
// all words are filled successfully. when (currentStage) {
LocalSoftwareKeyboardController.current?.hide() RestoreStage.Seed -> {
SecureScreen()
RestoreComplete(onComplete = onFinished) RestoreSeedMainContent(
userWordList = userWordList,
textState = textState,
setTextState = { textState = it },
focusRequester = focusRequester,
parseResult = parseResult,
paste = paste,
goNext = { restoreState.goNext() },
modifier = Modifier.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding()
)
)
}
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
// input on prior screens
LocalSoftwareKeyboardController.current?.hide()
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,7 +246,9 @@ private fun RestoreTopAppBar(onBack: () -> Unit, onClear: () -> Unit) {
} }
}, },
actions = { actions = {
NavigationButton(onClick = onClear, stringResource(R.string.restore_button_clear)) if (isShowClear) {
NavigationButton(onClick = onClear, stringResource(R.string.restore_button_clear))
}
} }
) )
} }
@ -190,46 +256,68 @@ private fun RestoreTopAppBar(onBack: () -> Unit, onClear: () -> Unit) {
// 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)
)
}
Spacer(modifier = Modifier.weight(MINIMAL_WEIGHT))
PrimaryButton(
onClick = goNext,
text = stringResource(id = R.string.restore_seed_button_restore),
enabled = isSeedValid
)
} }
// Cause text field to refocus 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) {
focusRequester.requestFocus() if (!isSeedValid) {
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
} }
} }

View File

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

View File

@ -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 wallets birthday?</string>
<string name="restore_birthday_body">This will allow a faster sync. If you dont know the wallets birthday, dont 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 dont 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>

View File

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