[#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 {
val NETWORK = ZcashNetwork.Testnet
val NETWORK = ZcashNetwork.Mainnet
// These came from the mainnet 1500000.json file
@Suppress("MagicNumber")
val BIRTHDAY = BlockHeight.new(ZcashNetwork.Mainnet, 1500000L)
val BIRTHDAY = BlockHeight.new(ZcashNetwork.Mainnet, 626603L)
val SEED_PHRASE = SeedPhraseFixture.new()

View File

@ -23,7 +23,7 @@ sealed class SeedPhraseValidation {
@Suppress("SwallowedException")
return try {
val stringified = list.joinToString(SeedPhrase.DEFAULT_DELIMITER)
withContext(Dispatchers.Main) {
withContext(Dispatchers.Default) {
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.test.filters.MediumTest
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.ui.common.LocalScreenSecurity
import co.electriccoin.zcash.ui.common.ScreenSecurity
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 kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -41,8 +43,12 @@ class RestoreViewSecuredScreenTest : UiTestPrerequisites() {
CompositionLocalProvider(LocalScreenSecurity provides screenSecurity) {
ZcashTheme {
RestoreWallet(
ZcashNetwork.Mainnet,
RestoreState(),
Mnemonics.getCachedWords(Locale.ENGLISH.language).toPersistentSet(),
WordList(emptyList()),
restoreHeight = null,
setRestoreHeight = {},
onBack = { },
paste = { "" },
onFinished = { }

View File

@ -1,8 +1,8 @@
package co.electriccoin.zcash.ui.screen.restore.view
import android.content.Context
import android.view.inputmethod.InputMethodManager
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasTestTag
@ -16,23 +16,27 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.filters.MediumTest
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.ZcashNetwork
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.design.theme.ZcashTheme
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.test.getAppContext
import co.electriccoin.zcash.ui.test.getStringResource
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.assertNull
class RestoreViewTest : UiTestPrerequisites() {
@get:Rule
@ -40,23 +44,8 @@ class RestoreViewTest : UiTestPrerequisites() {
@Test
@MediumTest
fun keyboard_appears_on_launch() {
newTestSetup(emptyList())
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())
fun seed_autocomplete_suggestions_appear() {
newTestSetup()
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
it.performTextInput("ab")
@ -76,8 +65,8 @@ class RestoreViewTest : UiTestPrerequisites() {
@Test
@MediumTest
fun choose_autocomplete() {
newTestSetup(emptyList())
fun seed_choose_autocomplete() {
newTestSetup()
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
it.performTextInput("ab")
@ -102,8 +91,8 @@ class RestoreViewTest : UiTestPrerequisites() {
@Test
@MediumTest
fun type_full_word() {
newTestSetup(emptyList())
fun seed_type_full_word() {
newTestSetup()
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
it.performTextInput("abandon")
@ -128,56 +117,28 @@ class RestoreViewTest : UiTestPrerequisites() {
@Test
@MediumTest
fun invalid_phrase_does_not_progress() {
newTestSetup(generateSequence { "abandon" }.take(SeedPhrase.SEED_PHRASE_SIZE).toList())
fun seed_invalid_phrase_does_not_progress() {
newTestSetup(initialWordsList = generateSequence { "abandon" }.take(SeedPhrase.SEED_PHRASE_SIZE).toList())
composeTestRule.onNodeWithText(getStringResource(R.string.restore_complete_header)).also {
it.assertDoesNotExist()
composeTestRule.onNodeWithText(getStringResource(R.string.restore_seed_button_restore)).also {
it.assertIsNotEnabled()
}
}
@Test
@MediumTest
fun finish_appears_after_24_words() {
newTestSetup(SeedPhraseFixture.new().split)
fun seed_finish_appears_after_24_words() {
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()
}
}
@Test
@MediumTest
fun click_take_to_wallet() {
val testSetup = newTestSetup(SeedPhraseFixture.new().split)
assertEquals(0, testSetup.getOnFinishedCount())
composeTestRule.onNodeWithText(getStringResource(R.string.restore_button_see_wallet)).also {
it.performClick()
}
assertEquals(1, testSetup.getOnFinishedCount())
}
@Test
@MediumTest
fun back() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnBackCount())
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.restore_back_content_description)).also {
it.performClick()
}
assertEquals(1, testSetup.getOnBackCount())
}
@Test
@MediumTest
fun clear() {
newTestSetup(listOf("abandon"))
fun seed_clear() {
newTestSetup(initialWordsList = listOf("abandon"))
composeTestRule.onNode(hasText("abandon") and hasTestTag(CommonTag.CHIP), useUnmergedTree = true).also {
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>) {
private val state = WordList(initialState)
composeTestRule.onNodeWithText(getStringResource(R.string.restore_birthday_button_skip)).also {
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 onFinishedCount = AtomicInteger(0)
private val restoreHeight = MutableStateFlow<BlockHeight?>(null)
fun getUserInputWords(): List<String> {
composeTestRule.waitForIdle()
return wordList.current.value
}
fun getStage(): RestoreStage {
composeTestRule.waitForIdle()
return state.current.value
}
fun getRestoreHeight(): BlockHeight? {
composeTestRule.waitForIdle()
return restoreHeight.value
}
fun getOnBackCount(): Int {
composeTestRule.waitForIdle()
return onBackCount.get()
@ -220,8 +387,14 @@ class RestoreViewTest : UiTestPrerequisites() {
composeTestRule.setContent {
ZcashTheme {
RestoreWallet(
Mnemonics.getCachedWords(Locale.ENGLISH.language).toPersistentSet(),
ZcashNetwork.Mainnet,
state,
Mnemonics.getCachedWords(Locale.ENGLISH.language).toPersistentSet(),
wordList,
restoreHeight = restoreHeight.collectAsState().value,
setRestoreHeight = {
restoreHeight.value = it
},
onBack = {
onBackCount.incrementAndGet()
},

View File

@ -2,13 +2,13 @@
package co.electriccoin.zcash.ui.screen.onboarding
import android.content.ClipboardManager
import android.content.Context
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.BlockHeight
import cash.z.ecc.android.sdk.model.PersistableWallet
import cash.z.ecc.android.sdk.model.SeedPhrase
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.ShortOnboarding
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
import co.electriccoin.zcash.ui.screen.restore.WrapRestore
@Composable
internal fun MainActivity.WrapOnboarding() {
WrapOnboarding(this)
}
@Suppress("LongMethod")
@Composable
internal fun WrapOnboarding(
activity: ComponentActivity
@ -55,7 +54,8 @@ internal fun WrapOnboarding(
persistExistingWalletWithSeedPhrase(
applicationContext,
walletViewModel,
SeedPhraseFixture.new()
SeedPhraseFixture.new(),
birthday = null
)
} else {
walletViewModel.persistNewWallet()
@ -71,7 +71,8 @@ internal fun WrapOnboarding(
persistExistingWalletWithSeedPhrase(
applicationContext,
walletViewModel,
SeedPhraseFixture.new()
SeedPhraseFixture.new(),
birthday = null
)
} else {
onboardingViewModel.setIsImporting(true)
@ -82,7 +83,8 @@ internal fun WrapOnboarding(
persistExistingWalletWithSeedPhrase(
applicationContext,
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
* 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
* 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,
walletViewModel: WalletViewModel,
seedPhrase: SeedPhrase
seedPhrase: SeedPhrase,
birthday: BlockHeight?
) {
walletViewModel.persistBackupComplete()
val network = ZcashNetwork.fromResources(context)
val restoredWallet = PersistableWallet(
network,
null,
birthday,
seedPhrase
)
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 {
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 AUTOCOMPLETE_LAYOUT = "autocomplete_layout"
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
import cash.z.ecc.android.sdk.model.SeedPhrase
import cash.z.ecc.sdk.model.SeedPhraseValidation
import co.electriccoin.zcash.ui.common.first
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
@ -17,7 +19,11 @@ class WordList(initial: List<String> = emptyList()) {
}
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

View File

@ -1,9 +1,10 @@
@file:Suppress("TooManyFunctions")
package co.electriccoin.zcash.ui.screen.restore.view
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.unit.dp
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 co.electriccoin.zcash.spackle.model.Index
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.NavigationButton
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.dimens
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.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.wordValidation
import kotlinx.collections.immutable.ImmutableList
@ -81,6 +88,8 @@ fun PreviewRestore() {
ZcashTheme(darkTheme = true) {
GradientSurface {
RestoreWallet(
ZcashNetwork.Mainnet,
restoreState = RestoreState(RestoreStage.Seed),
completeWordList = persistentHashSetOf(
"abandon",
"ability",
@ -94,6 +103,8 @@ fun PreviewRestore() {
"ribbon"
),
userWordList = WordList(listOf("abandon", "absorb")),
restoreHeight = null,
setRestoreHeight = {},
onBack = {},
paste = { "" },
onFinished = {}
@ -113,11 +124,21 @@ fun PreviewRestoreComplete() {
}
// 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)
@Composable
fun RestoreWallet(
zcashNetwork: ZcashNetwork,
restoreState: RestoreState,
completeWordList: ImmutableSet<String>,
userWordList: WordList,
restoreHeight: BlockHeight?,
setRestoreHeight: (BlockHeight?) -> Unit,
onBack: () -> Unit,
paste: () -> String?,
onFinished: () -> Unit
@ -126,49 +147,92 @@ fun RestoreWallet(
val focusRequester = remember { FocusRequester() }
val parseResult = ParseResult.new(completeWordList, textState)
SecureScreen()
userWordList.wordValidation().collectAsState(null).value?.let { seedPhraseValidation ->
if (seedPhraseValidation !is SeedPhraseValidation.Valid) {
Scaffold(topBar = {
RestoreTopAppBar(onBack = onBack, onClear = { userWordList.set(emptyList()) })
}, bottomBar = {
Column(Modifier.verticalScroll(rememberScrollState())) {
Warn(parseResult)
Autocomplete(parseResult = parseResult, {
textState = ""
userWordList.append(listOf(it))
focusRequester.requestFocus()
})
NextWordTextField(
val currentStage = restoreState.current.collectAsStateWithLifecycle().value
Scaffold(
topBar = {
RestoreTopAppBar(
onBack = {
if (currentStage.hasPrevious()) {
restoreState.goPrevious()
} else {
onBack()
}
},
isShowClear = currentStage == RestoreStage.Seed,
onClear = { userWordList.set(emptyList()) }
)
},
bottomBar = {
when (currentStage) {
RestoreStage.Seed -> {
RestoreSeedBottomBar(
userWordList = userWordList,
parseResult = parseResult,
text = textState,
setText = { textState = it },
modifier = Modifier.focusRequester(focusRequester)
setTextState = { textState = it },
focusRequester = focusRequester
)
}
}) { paddingValues ->
RestoreMainContent(
paddingValues = paddingValues,
userWordList = userWordList,
onTextStateChange = { textState = it },
focusRequester = focusRequester,
parseResult = parseResult,
paste = paste
)
RestoreStage.Birthday -> {
// No content
}
RestoreStage.Complete -> {
// No content
}
}
} else {
// In some cases we need to hide the software keyboard manually, as it stays shown after
// all words are filled successfully.
LocalSoftwareKeyboardController.current?.hide()
},
content = { paddingValues ->
when (currentStage) {
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
@OptIn(ExperimentalMaterial3Api::class)
private fun RestoreTopAppBar(onBack: () -> Unit, onClear: () -> Unit) {
private fun RestoreTopAppBar(onBack: () -> Unit, isShowClear: Boolean, onClear: () -> Unit) {
TopAppBar(
title = { Text(text = stringResource(id = R.string.restore_title)) },
navigationIcon = {
@ -182,7 +246,9 @@ private fun RestoreTopAppBar(onBack: () -> Unit, onClear: () -> Unit) {
}
},
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] https://github.com/zcash/secant-android-wallet/issues/672
@OptIn(ExperimentalComposeUiApi::class)
@Suppress("UNUSED_PARAMETER", "LongParameterList")
@Composable
private fun RestoreMainContent(
paddingValues: PaddingValues,
private fun RestoreSeedMainContent(
userWordList: WordList,
onTextStateChange: (String) -> Unit,
textState: String,
setTextState: (String) -> Unit,
focusRequester: FocusRequester,
parseResult: ParseResult,
paste: () -> String?
paste: () -> String?,
goNext: () -> Unit,
modifier: Modifier = Modifier
) {
val currentUserWordList = userWordList.current.collectAsStateWithLifecycle().value
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
if (parseResult is ParseResult.Add) {
onTextStateChange("")
setTextState("")
userWordList.append(parseResult.words)
}
val isSeedValid = userWordList.wordValidation().collectAsState(null).value is SeedPhraseValidation.Valid
Column(
Modifier
.fillMaxWidth()
.fillMaxHeight()
.verticalScroll(scrollState)
.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding()
)
modifier.then(Modifier.verticalScroll(scrollState))
) {
Body(
modifier = Modifier.padding(16.dp),
text = stringResource(id = R.string.restore_instructions)
modifier = Modifier.padding(dimens.spacingDefault),
text = stringResource(id = R.string.restore_seed_instructions)
)
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) {
focusRequester.requestFocus()
if (!isSeedValid) {
focusRequester.requestFocus()
}
scope.launch {
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
private fun ChipGridWithText(
userWordList: ImmutableList<String>
@ -276,21 +387,22 @@ private fun NextWordTextField(
setText: (String) -> Unit,
modifier: Modifier = Modifier
) {
/*
* Treat the user input as a password, but disable the transformation to obscure input.
*/
Surface(
modifier = modifier
.fillMaxWidth()
.padding(4.dp),
.padding(dimens.spacingTiny),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondary,
shadowElevation = 8.dp
) {
/*
* Treat the user input as a password for more secure input, but disable the transformation
* to obscure typing.
*/
TextField(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp)
.padding(dimens.spacingTiny)
.testTag(RestoreTag.SEED_WORD_TEXT_FIELD),
value = text,
onValueChange = setText,
@ -359,7 +471,7 @@ private fun Warn(parseResult: ParseResult) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
.padding(dimens.spacingTiny),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondary,
shadowElevation = 4.dp
@ -367,21 +479,85 @@ private fun Warn(parseResult: ParseResult) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
.padding(dimens.spacingTiny),
textAlign = TextAlign.Center,
text = if (parseResult.suggestions.isEmpty()) {
stringResource(id = R.string.restore_warning_no_suggestions)
stringResource(id = R.string.restore_seed_warning_no_suggestions)
} else {
stringResource(id = R.string.restore_warning_suggestions)
stringResource(id = R.string.restore_seed_warning_suggestions)
}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RestoreComplete(onComplete: () -> Unit) {
Column {
private fun RestoreBirthday(
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))
Body(stringResource(R.string.restore_complete_info))
Spacer(
@ -390,6 +566,5 @@ private fun RestoreComplete(onComplete: () -> Unit) {
.weight(MINIMAL_WEIGHT)
)
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 cash.z.ecc.android.bip39.Mnemonics
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.screen.restore.model.RestoreStage
import co.electriccoin.zcash.ui.screen.restore.state.RestoreState
import co.electriccoin.zcash.ui.screen.restore.state.WordList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.flow
@ -20,6 +26,20 @@ import java.util.Locale
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
*/
@ -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 {
// viewModelScope is constructed with Dispatchers.Main.immediate, so this will
// update the save state as soon as a change occurs.
userWordList.current.collectWith(viewModelScope) {
savedStateHandle[KEY_WORD_LIST] = ArrayList(it)
}
userBirthdayHeight.collectWith(viewModelScope) {
savedStateHandle[KEY_BIRTHDAY_HEIGHT] = it?.value
}
}
companion object {
private const val KEY_STAGE = "stage" // $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_title">Wallet import</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_warning_no_suggestions">This word is not in the seed phrase dictionary.</string>
<string name="restore_button_clear">Clear</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_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_add_birthday">Add a wallet birthday</string>
</resources>

View File

@ -5,7 +5,9 @@ package co.electroniccoin.zcash.ui.screenshot
import android.content.Context
import android.os.Build
import android.os.LocaleList
import androidx.activity.viewModels
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
@ -30,6 +32,7 @@ import androidx.test.filters.MediumTest
import androidx.test.filters.SdkSuppress
import cash.z.ecc.android.sdk.fixture.WalletAddressFixture
import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.SeedPhrase
import cash.z.ecc.sdk.fixture.SeedPhraseFixture
import co.electriccoin.zcash.configuration.model.map.StringConfiguration
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.home.viewmodel.SecretState
import co.electriccoin.zcash.ui.screen.restore.RestoreTag
import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
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) {
// TODO [#286]: Screenshot tests fail on Firebase Test Lab
// 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 {
it.assertExists()
}
takeScreenshot(tag, "Import 3")
takeScreenshot(tag, "Import 4")
}
@Test