diff --git a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/PersistableWalletFixture.kt b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/PersistableWalletFixture.kt index 21b301b8..12038bee 100644 --- a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/PersistableWalletFixture.kt +++ b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/PersistableWalletFixture.kt @@ -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() diff --git a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/SeedPhraseValidation.kt b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/SeedPhraseValidation.kt index e95f01f3..ff216317 100644 --- a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/SeedPhraseValidation.kt +++ b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/SeedPhraseValidation.kt @@ -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() } diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewAndroidTest.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewAndroidTest.kt new file mode 100644 index 00000000..e072b56f --- /dev/null +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewAndroidTest.kt @@ -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 = 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) +} diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewSecuredScreenTest.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewSecuredScreenTest.kt index fe5f3995..293a8ba9 100644 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewSecuredScreenTest.kt +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewSecuredScreenTest.kt @@ -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 = { } diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewTest.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewTest.kt index 89962161..d0b4b7f2 100644 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewTest.kt +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewTest.kt @@ -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 = 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) { - 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 = emptyList() + ) = TestSetup(composeTestRule, initialStage, initialWordsList) + + internal class TestSetup( + private val composeTestRule: ComposeContentTestRule, + initialStage: RestoreStage, + initialWordsList: List + ) { + private val state = RestoreState(initialStage) + + private val wordList = WordList(initialWordsList) private val onBackCount = AtomicInteger(0) private val onFinishedCount = AtomicInteger(0) + private val restoreHeight = MutableStateFlow(null) + fun getUserInputWords(): List { + 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() }, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/AndroidOnboarding.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/AndroidOnboarding.kt index 122a96e0..46d4ae09 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/AndroidOnboarding.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/AndroidOnboarding.kt @@ -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() - val onboardingViewModel by activity.viewModels() - val restoreViewModel by activity.viewModels() - - 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) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/AndroidRestore.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/AndroidRestore.kt new file mode 100644 index 00000000..19f36384 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/AndroidRestore.kt @@ -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() + val onboardingViewModel by activity.viewModels() + val restoreViewModel by activity.viewModels() + + 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 + ) + } + ) + } + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/RestoreTag.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/RestoreTag.kt index 8e3fa3db..9cb6f4e6 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/RestoreTag.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/RestoreTag.kt @@ -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" diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/model/RestoreStage.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/model/RestoreStage.kt new file mode 100644 index 00000000..c242abbb --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/model/RestoreStage.kt @@ -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)] +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/state/RestoreState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/state/RestoreState.kt new file mode 100644 index 00000000..39e2ee72 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/state/RestoreState.kt @@ -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 = mutableState + + fun goNext() { + mutableState.value = current.value.getNext() + } + + fun goPrevious() { + mutableState.value = current.value.getPrevious() + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/state/WordList.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/state/WordList.kt index 5b6e11c9..75a03fd1 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/state/WordList.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/state/WordList.kt @@ -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 = emptyList()) { } fun append(words: List) { - 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 diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreView.kt index f05ef340..d337a272 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreView.kt @@ -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, 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 @@ -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 } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/viewmodel/RestoreViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/viewmodel/RestoreViewModel.kt index 96f525fc..faabf891 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/viewmodel/RestoreViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/viewmodel/RestoreViewModel.kt @@ -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(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 = run { + val initialValue: BlockHeight? = savedStateHandle.get(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 } } diff --git a/ui-lib/src/main/res/ui/restore/values/strings.xml b/ui-lib/src/main/res/ui/restore/values/strings.xml index c6b29c0e..aa3c0b7c 100644 --- a/ui-lib/src/main/res/ui/restore/values/strings.xml +++ b/ui-lib/src/main/res/ui/restore/values/strings.xml @@ -2,15 +2,21 @@ Create a wallet Wallet import Back - Clear - You can import your backed up wallet by entering your backup recovery phrase (aka seed phrase) now. - This word is not in the seed phrase dictionary. Please select the correct one from the suggestions. - This word is not in the seed phrase dictionary. + Clear + You can import your backed up wallet by entering your backup recovery phrase (aka seed phrase) now. + Restore wallet + This word is not in the seed phrase dictionary. Please select the correct one from the suggestions. + This word is not in the seed phrase dictionary. + + Do you know the wallet’s birthday? + This will allow a faster sync. If you don’t know the wallet’s birthday, don’t worry. + Birthday height + Restore with this birthday + I don’t know the birthday Seed phrase imported! - We will now scan the blockchain to find your transactions and balance. You can do this faster by adding a wallet birthday below. + We will now scan the blockchain to find your transactions and balance. Take me to my wallet - Add a wallet birthday diff --git a/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt b/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt index 1377ff8c..aab36998 100644 --- a/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt +++ b/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt @@ -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().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