[#16] Skeleton for wallet creation
In addition to the UI, this implements improved state management for the WalletViewModel which emits a sealed class now. Work remaining for followup PRs: - Use drag-and-drop for chips instead of dropdown menus #85 - Add red background color for test fail screen #86 - Add images for various screens (depends on #66 #64) - Implement copy to clipboard #49
This commit is contained in:
parent
ec983f1f8f
commit
9db77e2afe
|
@ -35,4 +35,5 @@ If you plan to fork the project to create a new app of your own, please make the
|
|||
1. When the code coverage Gradle property `IS_COVERAGE_ENABLED` is enabled, the debug app APK cannot be run. The coverage flag should therefore only be set when running automated tests.
|
||||
1. Test coverage for Compose code will be low, due to [known limitations](https://github.com/jacoco/jacoco/issues/1208) in the interaction between Compose and Jacoco.
|
||||
1. Adding the `espresso-contrib` dependency will cause builds to fail, due to conflicting classes. This is a [known issue](https://github.com/zcash/zcash-android-wallet-sdk/issues/306) with the Zcash Android SDK.
|
||||
1. Android Studio will warn about the Gradle checksum. This is a [known issue](https://github.com/gradle/gradle/issues/9361) and can be safely ignored.
|
||||
1. Android Studio will warn about the Gradle checksum. This is a [known issue](https://github.com/gradle/gradle/issues/9361) and can be safely ignored.
|
||||
1. During app first launch, the following exception starting with `AndroidKeysetManager: keyset not found, will generate a new one` is printed twice. This exception is not an error, and the code is not being invoked twice.
|
|
@ -17,7 +17,7 @@
|
|||
clients. -->
|
||||
<activity-alias
|
||||
android:name=".LauncherActivity"
|
||||
android:label="@string/"
|
||||
android:label="@string/app_name"
|
||||
android:targetActivity="cash.z.ecc.ui.MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">Demo App</string>
|
||||
<string name="app_name">Zcash Wallet</string>
|
||||
</resources>
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
<resources>
|
||||
<string name="app_name">Mainnet Demo</string>
|
||||
<string name="network_name">Mainnet</string>
|
||||
</resources>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Overlay from sdk-ext-lib. -->
|
||||
<bool name="zcash_is_testnet">true</bool>
|
||||
</resources>
|
|
@ -1,4 +1,4 @@
|
|||
<resources>
|
||||
<string name="app_name">Testnet Demo</string>
|
||||
<string name="app_name">Zcash Testnet Wallet</string>
|
||||
<string name="network_name">Testnet</string>
|
||||
</resources>
|
||||
|
|
|
@ -81,6 +81,6 @@ class EncryptedPreferenceProviderTest {
|
|||
|
||||
companion object {
|
||||
private val FILENAME = "encrypted_preference_test"
|
||||
private suspend fun new() = EncryptedPreferenceProvider.new(ApplicationProvider.getApplicationContext(), FILENAME)
|
||||
private suspend fun new() = AndroidPreferenceProvider.newEncrypted(ApplicationProvider.getApplicationContext(), FILENAME)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
package co.electriccoin.zcash.preference
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.filters.SmallTest
|
||||
import co.electriccoin.zcash.preference.test.fixture.StringDefaultPreferenceFixture
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
// Areas that are not covered yet:
|
||||
// 1. Test observer behavior
|
||||
class StandardPreferenceProviderTest {
|
||||
/*
|
||||
* Note: This test relies on Test Orchestrator to avoid issues with multiple runs. Specifically,
|
||||
* it purges the preference file and avoids corruption due to multiple instances of the
|
||||
* EncryptedPreferenceProvider.
|
||||
*/
|
||||
|
||||
private var isRun = false
|
||||
|
||||
@Before
|
||||
fun checkUsingOrchestrator() {
|
||||
check(!isRun) { "State appears to be retained between test method invocations; verify that Test Orchestrator is enabled and then re-run the tests" }
|
||||
|
||||
isRun = true
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun put_and_get_string() = runBlocking {
|
||||
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
|
||||
|
||||
val preferenceProvider = new().apply {
|
||||
putString(StringDefaultPreferenceFixture.KEY, expectedValue)
|
||||
}
|
||||
|
||||
assertEquals(expectedValue, StringDefaultPreferenceFixture.new().getValue(preferenceProvider))
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun hasKey_false() = runBlocking {
|
||||
val preferenceProvider = new()
|
||||
|
||||
assertFalse(preferenceProvider.hasKey(StringDefaultPreferenceFixture.new().key))
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun put_and_check_key() = runBlocking {
|
||||
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
|
||||
|
||||
val preferenceProvider = new().apply {
|
||||
putString(StringDefaultPreferenceFixture.KEY, expectedValue)
|
||||
}
|
||||
|
||||
assertTrue(preferenceProvider.hasKey(StringDefaultPreferenceFixture.new().key))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val FILENAME = "encrypted_preference_test"
|
||||
private suspend fun new() = AndroidPreferenceProvider.newStandard(ApplicationProvider.getApplicationContext(), FILENAME)
|
||||
}
|
||||
}
|
|
@ -18,7 +18,7 @@ import kotlinx.coroutines.withContext
|
|||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Provides encrypted shared preferences.
|
||||
* Provides an Android implementation of shared preferences.
|
||||
*
|
||||
* This class is thread-safe.
|
||||
*
|
||||
|
@ -30,7 +30,7 @@ import java.util.concurrent.Executors
|
|||
* Implementation note: EncryptedSharedPreferences are not thread-safe, so this implementation
|
||||
* confines them to a single background thread.
|
||||
*/
|
||||
class EncryptedPreferenceProvider(
|
||||
class AndroidPreferenceProvider(
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
private val dispatcher: CoroutineDispatcher
|
||||
) : PreferenceProvider {
|
||||
|
@ -71,7 +71,21 @@ class EncryptedPreferenceProvider(
|
|||
}.flowOn(dispatcher)
|
||||
|
||||
companion object {
|
||||
suspend fun new(context: Context, filename: String): PreferenceProvider {
|
||||
suspend fun newStandard(context: Context, filename: String): PreferenceProvider {
|
||||
/*
|
||||
* Because of this line, we don't want multiple instances of this object created
|
||||
* because we don't clean up the thread afterwards.
|
||||
*/
|
||||
val singleThreadedDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
|
||||
val sharedPreferences = withContext(singleThreadedDispatcher) {
|
||||
context.getSharedPreferences(filename, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
return AndroidPreferenceProvider(sharedPreferences, singleThreadedDispatcher)
|
||||
}
|
||||
|
||||
suspend fun newEncrypted(context: Context, filename: String): PreferenceProvider {
|
||||
/*
|
||||
* Because of this line, we don't want multiple instances of this object created
|
||||
* because we don't clean up the thread afterwards.
|
||||
|
@ -96,7 +110,7 @@ class EncryptedPreferenceProvider(
|
|||
)
|
||||
}
|
||||
|
||||
return EncryptedPreferenceProvider(sharedPreferences, singleThreadedDispatcher)
|
||||
return AndroidPreferenceProvider(sharedPreferences, singleThreadedDispatcher)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package cash.z.ecc.sdk.type
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.filters.SmallTest
|
||||
import cash.z.ecc.android.sdk.type.ZcashNetwork
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ZcashNetworkTest {
|
||||
@SmallTest
|
||||
@Test
|
||||
fun mainnet() {
|
||||
assertEquals(ZcashNetwork.Mainnet, ZcashNetwork.fromResources(ApplicationProvider.getApplicationContext()))
|
||||
}
|
||||
}
|
|
@ -14,7 +14,7 @@ import kotlinx.coroutines.withContext
|
|||
// Synchronizer needs a Companion object
|
||||
// https://github.com/zcash/zcash-android-wallet-sdk/issues/310
|
||||
object SynchronizerCompanion {
|
||||
suspend fun load(context: Context, persistableWallet: PersistableWallet) {
|
||||
suspend fun load(context: Context, persistableWallet: PersistableWallet): Synchronizer {
|
||||
val config = persistableWallet.toConfig()
|
||||
val initializer = withContext(Dispatchers.IO) { Initializer(context, config) }
|
||||
return withContext(Dispatchers.IO) { Synchronizer(initializer) }
|
||||
|
@ -24,11 +24,15 @@ object SynchronizerCompanion {
|
|||
private suspend fun PersistableWallet.deriveViewingKey(): UnifiedViewingKey {
|
||||
// Dispatcher needed because SecureRandom is loaded, which is slow and performs IO
|
||||
// https://github.com/zcash/kotlin-bip39/issues/13
|
||||
val bip39Seed = withContext(Dispatchers.IO) { Mnemonics.MnemonicCode(seedPhrase).toSeed() }
|
||||
val bip39Seed = withContext(Dispatchers.IO) {
|
||||
Mnemonics.MnemonicCode(seedPhrase.phrase).toSeed()
|
||||
}
|
||||
|
||||
// Dispatchers needed until an SDK is published with the implementation of
|
||||
// https://github.com/zcash/zcash-android-wallet-sdk/issues/269
|
||||
val viewingKey = withContext(Dispatchers.IO) { DerivationTool.deriveUnifiedViewingKeys(bip39Seed, network)[0] }
|
||||
val viewingKey = withContext(Dispatchers.IO) {
|
||||
DerivationTool.deriveUnifiedViewingKeys(bip39Seed, network)[0]
|
||||
}
|
||||
|
||||
return viewingKey
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package cash.z.ecc.sdk.fixture
|
|||
import cash.z.ecc.android.sdk.type.WalletBirthday
|
||||
import cash.z.ecc.android.sdk.type.ZcashNetwork
|
||||
import cash.z.ecc.sdk.model.PersistableWallet
|
||||
import cash.z.ecc.sdk.model.SeedPhrase
|
||||
|
||||
object PersistableWalletFixture {
|
||||
|
||||
|
@ -10,7 +11,12 @@ object PersistableWalletFixture {
|
|||
|
||||
val BIRTHDAY = WalletBirthdayFixture.new()
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
val SEED_PHRASE = "still champion voice habit trend flight survey between bitter process artefact blind carbon truly provide dizzy crush flush breeze blouse charge solid fish spread"
|
||||
|
||||
fun new(network: ZcashNetwork = NETWORK, birthday: WalletBirthday = BIRTHDAY, seedPhrase: String = SEED_PHRASE) = PersistableWallet(network, birthday, seedPhrase)
|
||||
fun new(
|
||||
network: ZcashNetwork = NETWORK,
|
||||
birthday: WalletBirthday = BIRTHDAY,
|
||||
seedPhrase: String = SEED_PHRASE
|
||||
) = PersistableWallet(network, birthday, SeedPhrase(seedPhrase))
|
||||
}
|
|
@ -7,6 +7,7 @@ object WalletBirthdayFixture {
|
|||
const val HEIGHT = 1500000
|
||||
const val HASH = "00047a34c61409682f44640af9352023ad92f69b827d0f2b288f152ebea50f46"
|
||||
const val EPOCH_SECONDS = 1627076501L
|
||||
@Suppress("MaxLineLength")
|
||||
const val TREE = "01172b95f271c6af8f68388f08c8ef970db8ec8d8d61204ecb7b2bb2c38262b92d0010016284585a6c85dadfef27ff33f1403926b4bb391de92e8be797e4280cc4ca2971000001a1ff388639379c0120782b3929bd8871af797be4b651f694aa961bad65a9c12400000001d806c98bda9653d5ae22757eed750871e16e0fb657f52c3d771a4411668e84330001260f6e9fac0922f98d58afbcc3f391ac19d5d944081466929a33b99df19c0e6a0000013d2fd009bf8a22d68f720eac19c411c99014ed9c5f85d5942e15d1fc039e28680001f08f39275112dd8905b854170b7f247cf2df18454d4fa94e6e4f9320cca05f24011f8322ef806eb2430dc4a7a41c1b344bea5be946efc7b4349c1c9edb14ff9d39"
|
||||
|
||||
fun new(
|
|
@ -1,7 +1,14 @@
|
|||
package cash.z.ecc.sdk.model
|
||||
|
||||
import android.app.Application
|
||||
import cash.z.ecc.android.bip39.Mnemonics
|
||||
import cash.z.ecc.android.bip39.toEntropy
|
||||
import cash.z.ecc.android.sdk.tool.WalletBirthdayTool
|
||||
import cash.z.ecc.android.sdk.type.WalletBirthday
|
||||
import cash.z.ecc.android.sdk.type.ZcashNetwork
|
||||
import cash.z.ecc.sdk.type.fromResources
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
|
@ -10,7 +17,7 @@ import org.json.JSONObject
|
|||
data class PersistableWallet(
|
||||
val network: ZcashNetwork,
|
||||
val birthday: WalletBirthday,
|
||||
val seedPhrase: String
|
||||
val seedPhrase: SeedPhrase
|
||||
) {
|
||||
|
||||
/**
|
||||
|
@ -22,7 +29,7 @@ data class PersistableWallet(
|
|||
put(KEY_VERSION, VERSION_1)
|
||||
put(KEY_NETWORK_ID, network.id)
|
||||
put(KEY_BIRTHDAY, birthday.toJson())
|
||||
put(KEY_SEED_PHRASE, seedPhrase)
|
||||
put(KEY_SEED_PHRASE, seedPhrase.phrase)
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
|
@ -45,12 +52,34 @@ data class PersistableWallet(
|
|||
val birthday = WalletBirthdayCompanion.from(jsonObject.getJSONObject(KEY_BIRTHDAY))
|
||||
val seedPhrase = jsonObject.getString(KEY_SEED_PHRASE)
|
||||
|
||||
return PersistableWallet(ZcashNetwork.from(networkId), birthday, seedPhrase)
|
||||
return PersistableWallet(ZcashNetwork.from(networkId), birthday, SeedPhrase(seedPhrase))
|
||||
}
|
||||
else -> {
|
||||
throw IllegalArgumentException("Unsupported version $version")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return A new PersistableWallet with a random seed phrase.
|
||||
*/
|
||||
suspend fun new(application: Application): PersistableWallet {
|
||||
val zcashNetwork = ZcashNetwork.fromResources(application)
|
||||
// Dispatchers can be removed once a new SDK is released implementing
|
||||
// https://github.com/zcash/zcash-android-wallet-sdk/issues/269
|
||||
val walletBirthday = withContext(Dispatchers.IO) {
|
||||
WalletBirthdayTool.loadNearest(application, zcashNetwork)
|
||||
}
|
||||
val seedPhrase = newSeedPhrase()
|
||||
|
||||
return PersistableWallet(zcashNetwork, walletBirthday, seedPhrase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Using IO context because of https://github.com/zcash/kotlin-bip39/issues/13
|
||||
private suspend fun newMnemonic() = withContext(Dispatchers.IO) {
|
||||
Mnemonics.MnemonicCode(cash.z.ecc.android.bip39.Mnemonics.WordCount.COUNT_24.toEntropy()).words
|
||||
}
|
||||
|
||||
private suspend fun newSeedPhrase() = SeedPhrase(newMnemonic().joinToString(separator = " ") { it.concatToString() })
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
package cash.z.ecc.sdk.model
|
||||
|
||||
data class SeedPhrase(val phrase: String) {
|
||||
val split = phrase.split(" ")
|
||||
|
||||
init {
|
||||
require(SEED_PHRASE_SIZE == split.size) {
|
||||
"Seed phrase must split into $SEED_PHRASE_SIZE words but was ${split.size}"
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
// For security, intentionally override the toString method to reduce risk of accidentally logging secrets
|
||||
return "SeedPhrase"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SEED_PHRASE_SIZE = 24
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package cash.z.ecc.sdk.type
|
||||
|
||||
import android.app.Application
|
||||
import cash.z.ecc.android.sdk.type.ZcashNetwork
|
||||
import cash.z.ecc.sdk.ext.R
|
||||
|
||||
/*
|
||||
* Note: If we end up having trouble with this implementation in the future, especially with the rollout
|
||||
* of disabling transitive resources, we do have alternative implementations.
|
||||
*
|
||||
* Probably the most straightforward and high performance would be to implement an interface, have
|
||||
* the Application class implement the interface, and allow this to cast the Application object to
|
||||
* get the value. If the Application does not implement the interface, then the Mainnet can be the
|
||||
* default.
|
||||
*
|
||||
* Alternatives include
|
||||
* - Adding build variants to sdk-ext-lib, ui-lib, and app which gets complex. The current approach
|
||||
* or the approach outlined above only requires build variants on the app module.
|
||||
* - Using a ContentProvider for dynamic injection, where the URI is defined
|
||||
* - Using AndroidManifest metadata for dynamic injection
|
||||
*/
|
||||
/**
|
||||
* @return Zcash network determined from resources. A resource overlay of [R.bool.zcash_is_testnet]
|
||||
* can be used for different build variants to change the network type.
|
||||
*/
|
||||
fun ZcashNetwork.Companion.fromResources(application: Application) =
|
||||
if (application.resources.getBoolean(R.bool.zcash_is_testnet)) {
|
||||
ZcashNetwork.Testnet
|
||||
} else {
|
||||
ZcashNetwork.Mainnet
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Resource that can be overlaid downstream with build variants to support
|
||||
testnet builds. -->
|
||||
<bool name="zcash_is_testnet">false</bool>
|
||||
</resources>
|
|
@ -28,7 +28,8 @@ android {
|
|||
res.setSrcDirs(
|
||||
setOf(
|
||||
"src/main/res/ui/common",
|
||||
"src/main/res/ui/onboarding"
|
||||
"src/main/res/ui/onboarding",
|
||||
"src/main/res/ui/new_wallet"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
package cash.z.ecc.ui.screen.backup.model
|
||||
|
||||
import androidx.test.filters.SmallTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
|
||||
class BackupStageTest {
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun getProgress_first() {
|
||||
val progress = BackupStage.values().first().getProgress()
|
||||
|
||||
assertEquals(0, progress.current.value)
|
||||
assertEquals(4, progress.last.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun getProgress_last() {
|
||||
val progress = BackupStage.values().last().getProgress()
|
||||
|
||||
assertEquals(4, progress.current.value)
|
||||
assertEquals(4, progress.last.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun hasNext_boundary() {
|
||||
val last = BackupStage.values().last()
|
||||
|
||||
assertFalse(last.hasNext())
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun hasPrevious_boundary() {
|
||||
val last = BackupStage.values().first()
|
||||
|
||||
assertFalse(last.hasPrevious())
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun getNext_from_first() {
|
||||
val first = BackupStage.values().first()
|
||||
val next = first.getNext()
|
||||
|
||||
assertNotEquals(first, next)
|
||||
assertEquals(BackupStage.EducationRecoveryPhrase, next)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun getNext_boundary() {
|
||||
val last = BackupStage.values().last()
|
||||
|
||||
assertEquals(last, last.getNext())
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun getPrevious_from_last() {
|
||||
val last = BackupStage.values().last()
|
||||
val previous = last.getPrevious()
|
||||
|
||||
assertNotEquals(last, previous)
|
||||
assertEquals(BackupStage.Test, previous)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun getPrevious_boundary() {
|
||||
val first = BackupStage.values().first()
|
||||
|
||||
assertEquals(first, first.getPrevious())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,201 @@
|
|||
package cash.z.ecc.ui.screen.backup.view
|
||||
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onChildren
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.filters.MediumTest
|
||||
import cash.z.ecc.sdk.fixture.PersistableWalletFixture
|
||||
import cash.z.ecc.ui.R
|
||||
import cash.z.ecc.ui.screen.backup.BackupTags
|
||||
import cash.z.ecc.ui.screen.backup.model.BackupStage
|
||||
import cash.z.ecc.ui.screen.backup.state.BackupState
|
||||
import cash.z.ecc.ui.screen.backup.state.TestChoices
|
||||
import cash.z.ecc.ui.test.getStringResource
|
||||
import cash.z.ecc.ui.theme.ZcashTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class BackupViewTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
// Sanity check the TestSetup
|
||||
@Test
|
||||
@MediumTest
|
||||
fun verify_test_setup_stage_1() {
|
||||
val testSetup = newTestSetup(BackupStage.EducationOverview)
|
||||
|
||||
assertEquals(BackupStage.EducationOverview, testSetup.getStage())
|
||||
assertEquals(0, testSetup.getOnCompleteCallbackCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun verify_test_setup_stage_5() {
|
||||
val testSetup = newTestSetup(BackupStage.Complete)
|
||||
|
||||
assertEquals(BackupStage.Complete, testSetup.getStage())
|
||||
assertEquals(0, testSetup.getOnCompleteCallbackCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun test_pass() {
|
||||
val testSetup = newTestSetup(BackupStage.Test)
|
||||
|
||||
composeTestRule.onAllNodesWithTag(BackupTags.DROPDOWN_CHIP).also {
|
||||
it.assertCountEquals(4)
|
||||
|
||||
it[0].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[1].performClick()
|
||||
|
||||
it[1].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[0].performClick()
|
||||
|
||||
it[2].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[3].performClick()
|
||||
|
||||
it[3].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[2].performClick()
|
||||
}
|
||||
|
||||
assertEquals(BackupStage.Complete, testSetup.getStage())
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun test_fail() {
|
||||
val testSetup = newTestSetup(BackupStage.Test)
|
||||
|
||||
composeTestRule.onAllNodesWithTag(BackupTags.DROPDOWN_CHIP).also {
|
||||
it.assertCountEquals(4)
|
||||
|
||||
it[0].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[0].performClick()
|
||||
|
||||
it[1].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[1].performClick()
|
||||
|
||||
it[2].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[2].performClick()
|
||||
|
||||
it[3].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[3].performClick()
|
||||
}
|
||||
|
||||
assertEquals(BackupStage.Test, testSetup.getStage())
|
||||
|
||||
composeTestRule.onNode(hasText(getStringResource(R.string.new_wallet_4_header_ouch)))
|
||||
|
||||
composeTestRule.onNode(hasText(getStringResource(R.string.new_wallet_4_button_retry))).performClick()
|
||||
|
||||
assertEquals(BackupStage.Seed, testSetup.getStage())
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun last_stage_click_finish() {
|
||||
val testSetup = newTestSetup(BackupStage.Complete)
|
||||
|
||||
val goToWalletButton = composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_5_button_finished))
|
||||
goToWalletButton.performClick()
|
||||
|
||||
assertEquals(1, testSetup.getOnCompleteCallbackCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun last_stage_click_back_to_seed() {
|
||||
val testSetup = newTestSetup(BackupStage.Complete)
|
||||
|
||||
val newWalletButton = composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_5_button_back))
|
||||
newWalletButton.performClick()
|
||||
|
||||
assertEquals(0, testSetup.getOnCompleteCallbackCount())
|
||||
assertEquals(BackupStage.Seed, testSetup.getStage())
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun multi_stage_progression() {
|
||||
val testSetup = newTestSetup(BackupStage.EducationOverview)
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_1_button)).also {
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
assertEquals(BackupStage.EducationRecoveryPhrase, testSetup.getStage())
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_2_button)).also {
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
assertEquals(BackupStage.Seed, testSetup.getStage())
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_3_button_finished)).also {
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
assertEquals(BackupStage.Test, testSetup.getStage())
|
||||
|
||||
composeTestRule.onAllNodesWithTag(BackupTags.DROPDOWN_CHIP).also {
|
||||
it.assertCountEquals(4)
|
||||
|
||||
it[0].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[1].performClick()
|
||||
|
||||
it[1].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[0].performClick()
|
||||
|
||||
it[2].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[3].performClick()
|
||||
|
||||
it[3].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTags.DROPDOWN_MENU)).onChildren()[2].performClick()
|
||||
}
|
||||
|
||||
assertEquals(BackupStage.Complete, testSetup.getStage())
|
||||
|
||||
composeTestRule.onNode(hasText(getStringResource(R.string.new_wallet_5_button_finished))).performClick()
|
||||
|
||||
assertEquals(1, testSetup.getOnCompleteCallbackCount())
|
||||
}
|
||||
|
||||
private fun newTestSetup(initalStage: BackupStage) = TestSetup(composeTestRule, initalStage)
|
||||
|
||||
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initalStage: BackupStage) {
|
||||
private val state = BackupState(initalStage)
|
||||
|
||||
private var onCompleteCallbackCount = 0
|
||||
|
||||
fun getOnCompleteCallbackCount(): Int {
|
||||
composeTestRule.waitForIdle()
|
||||
return onCompleteCallbackCount
|
||||
}
|
||||
|
||||
fun getStage(): BackupStage {
|
||||
composeTestRule.waitForIdle()
|
||||
return state.current.value
|
||||
}
|
||||
|
||||
init {
|
||||
composeTestRule.setContent {
|
||||
ZcashTheme {
|
||||
BackupWallet(
|
||||
PersistableWalletFixture.new(),
|
||||
state,
|
||||
TestChoices(),
|
||||
onComplete = { onCompleteCallbackCount++ }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ import androidx.test.filters.MediumTest
|
|||
import cash.z.ecc.ui.R
|
||||
import cash.z.ecc.ui.screen.onboarding.model.OnboardingStage
|
||||
import cash.z.ecc.ui.screen.onboarding.state.OnboardingState
|
||||
import cash.z.ecc.ui.screen.onboarding.test.getStringResource
|
||||
import cash.z.ecc.ui.test.getStringResource
|
||||
import cash.z.ecc.ui.theme.ZcashTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
package cash.z.ecc.ui.screen.onboarding.test
|
||||
package cash.z.ecc.ui.test
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
|
@ -4,8 +4,13 @@ import android.os.Bundle
|
|||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import cash.z.ecc.sdk.model.PersistableWallet
|
||||
import cash.z.ecc.ui.screen.backup.view.BackupWallet
|
||||
import cash.z.ecc.ui.screen.backup.viewmodel.BackupViewModel
|
||||
import cash.z.ecc.ui.screen.home.view.Home
|
||||
import cash.z.ecc.ui.screen.home.viewmodel.WalletState
|
||||
import cash.z.ecc.ui.screen.home.viewmodel.WalletViewModel
|
||||
import cash.z.ecc.ui.screen.onboarding.view.Onboarding
|
||||
import cash.z.ecc.ui.screen.onboarding.viewmodel.OnboardingViewModel
|
||||
|
@ -16,26 +21,39 @@ class MainActivity : ComponentActivity() {
|
|||
private val walletViewModel by viewModels<WalletViewModel>()
|
||||
|
||||
private val onboardingViewModel by viewModels<OnboardingViewModel>()
|
||||
private val backupViewModel by viewModels<BackupViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
ZcashTheme {
|
||||
if (null == walletViewModel.persistableWallet.collectAsState(null).value) {
|
||||
// Optimized path to get to onboarding as quickly as possible
|
||||
Onboarding(
|
||||
onboardingState = onboardingViewModel.onboardingState,
|
||||
onImportWallet = { TODO("Implement wallet import") },
|
||||
onCreateWallet = { TODO("Implement wallet create") }
|
||||
)
|
||||
} else {
|
||||
if (null == walletViewModel.synchronizer.collectAsState(null).value) {
|
||||
// Continue displaying splash screen
|
||||
} else {
|
||||
Home()
|
||||
when (val walletState = walletViewModel.state.collectAsState().value) {
|
||||
WalletState.Loading -> {
|
||||
// For now, keep displaying splash screen
|
||||
}
|
||||
WalletState.NoWallet -> WrapOnboarding()
|
||||
is WalletState.NeedsBackup -> WrapBackup(walletState.persistableWallet)
|
||||
is WalletState.Ready -> Home(walletState.persistableWallet)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WrapBackup(persistableWallet: PersistableWallet) {
|
||||
BackupWallet(persistableWallet, backupViewModel.backupState, backupViewModel.testChoices) {
|
||||
walletViewModel.persistBackupComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WrapOnboarding() {
|
||||
Onboarding(
|
||||
onboardingState = onboardingViewModel.onboardingState,
|
||||
onImportWallet = { TODO("Implement wallet import") },
|
||||
onCreateWallet = {
|
||||
walletViewModel.createAndPersistWallet()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,14 +2,14 @@ package cash.z.ecc.ui.preference
|
|||
|
||||
import android.content.Context
|
||||
import cash.z.ecc.ui.util.Lazy
|
||||
import co.electriccoin.zcash.preference.EncryptedPreferenceProvider
|
||||
import co.electriccoin.zcash.preference.AndroidPreferenceProvider
|
||||
import co.electriccoin.zcash.preference.api.PreferenceProvider
|
||||
|
||||
object EncryptedPreferenceSingleton {
|
||||
|
||||
private const val PREF_FILENAME = "co.electriccoin.zcash.encrypted"
|
||||
|
||||
private val lazy = Lazy<Context, PreferenceProvider> { EncryptedPreferenceProvider.new(it, PREF_FILENAME) }
|
||||
private val lazy = Lazy<Context, PreferenceProvider> { AndroidPreferenceProvider.newEncrypted(it, PREF_FILENAME) }
|
||||
|
||||
suspend fun getInstance(context: Context) = lazy.getInstance(context)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
package cash.z.ecc.ui.preference
|
||||
|
||||
import co.electriccoin.zcash.preference.model.entry.BooleanPreferenceDefault
|
||||
import co.electriccoin.zcash.preference.model.entry.Key
|
||||
|
||||
object StandardPreferenceKeys {
|
||||
|
||||
/**
|
||||
* Whether the user has completed the backup flow for a newly created wallet.
|
||||
*/
|
||||
val IS_USER_BACKUP_COMPLETE = BooleanPreferenceDefault(Key("is_user_backup_complete"), false)
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package cash.z.ecc.ui.preference
|
||||
|
||||
import android.content.Context
|
||||
import cash.z.ecc.ui.util.Lazy
|
||||
import co.electriccoin.zcash.preference.AndroidPreferenceProvider
|
||||
import co.electriccoin.zcash.preference.api.PreferenceProvider
|
||||
|
||||
object StandardPreferenceSingleton {
|
||||
|
||||
private const val PREF_FILENAME = "co.electriccoin.zcash"
|
||||
|
||||
private val lazy = Lazy<Context, PreferenceProvider> { AndroidPreferenceProvider.newStandard(it, PREF_FILENAME) }
|
||||
|
||||
suspend fun getInstance(context: Context) = lazy.getInstance(context)
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package cash.z.ecc.ui.screen.backup
|
||||
|
||||
/**
|
||||
* These are only used for automated testing.
|
||||
*/
|
||||
object BackupTags {
|
||||
const val DROPDOWN_CHIP = "dropdown_chip"
|
||||
const val DROPDOWN_MENU = "dropdown_menu"
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package cash.z.ecc.ui.screen.backup.model
|
||||
|
||||
import cash.z.ecc.ui.screen.onboarding.model.Index
|
||||
import cash.z.ecc.ui.screen.onboarding.model.Progress
|
||||
|
||||
enum class BackupStage {
|
||||
// Note: the ordinal order is used to manage progression through each stage
|
||||
// so be careful if reordering these
|
||||
EducationOverview,
|
||||
EducationRecoveryPhrase,
|
||||
Seed,
|
||||
Test,
|
||||
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)]
|
||||
|
||||
/**
|
||||
* @return Last item in ordinal order. Returns the last item when it cannot go further forward.
|
||||
*/
|
||||
fun getProgress() = Progress(Index(ordinal), Index(values().size - 1))
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package cash.z.ecc.ui.screen.backup.state
|
||||
|
||||
import cash.z.ecc.ui.screen.backup.model.BackupStage
|
||||
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 BackupState(initialState: BackupStage = BackupStage.values().first()) {
|
||||
|
||||
private val mutableState = MutableStateFlow(initialState)
|
||||
|
||||
val current: StateFlow<BackupStage> = mutableState
|
||||
|
||||
fun hasNext() = current.value.hasNext()
|
||||
|
||||
fun goNext() {
|
||||
mutableState.value = current.value.getNext()
|
||||
}
|
||||
|
||||
fun goPrevious() {
|
||||
mutableState.value = current.value.getPrevious()
|
||||
}
|
||||
|
||||
fun goToSeed() {
|
||||
mutableState.value = BackupStage.Seed
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package cash.z.ecc.ui.screen.backup.state
|
||||
|
||||
import cash.z.ecc.ui.screen.onboarding.model.Index
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class TestChoices(initial: Map<Index, String?> = emptyMap()) {
|
||||
private val mutableState = MutableStateFlow<Map<Index, String?>>(HashMap(initial))
|
||||
|
||||
val current: StateFlow<Map<Index, String?>> = mutableState
|
||||
|
||||
fun set(map: Map<Index, String?>) {
|
||||
mutableState.value = HashMap(map)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,254 @@
|
|||
package cash.z.ecc.ui.screen.backup.view
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import cash.z.ecc.sdk.fixture.PersistableWalletFixture
|
||||
import cash.z.ecc.sdk.model.PersistableWallet
|
||||
import cash.z.ecc.ui.R
|
||||
import cash.z.ecc.ui.screen.backup.BackupTags
|
||||
import cash.z.ecc.ui.screen.backup.model.BackupStage
|
||||
import cash.z.ecc.ui.screen.backup.state.BackupState
|
||||
import cash.z.ecc.ui.screen.backup.state.TestChoices
|
||||
import cash.z.ecc.ui.screen.common.Body
|
||||
import cash.z.ecc.ui.screen.common.CHIP_GRID_ROW_SIZE
|
||||
import cash.z.ecc.ui.screen.common.Chip
|
||||
import cash.z.ecc.ui.screen.common.ChipGrid
|
||||
import cash.z.ecc.ui.screen.common.Header
|
||||
import cash.z.ecc.ui.screen.common.NavigationButton
|
||||
import cash.z.ecc.ui.screen.common.PrimaryButton
|
||||
import cash.z.ecc.ui.screen.common.TertiaryButton
|
||||
import cash.z.ecc.ui.screen.onboarding.model.Index
|
||||
import cash.z.ecc.ui.theme.MINIMAL_WEIGHT
|
||||
import cash.z.ecc.ui.theme.ZcashTheme
|
||||
|
||||
@Preview(device = Devices.PIXEL_4)
|
||||
@Composable
|
||||
fun ComposablePreview() {
|
||||
ZcashTheme(darkTheme = true) {
|
||||
BackupWallet(
|
||||
PersistableWalletFixture.new(),
|
||||
BackupState(BackupStage.Test),
|
||||
TestChoices(),
|
||||
onComplete = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param onComplete Callback when the user has completed the backup test.
|
||||
*/
|
||||
@Composable
|
||||
fun BackupWallet(
|
||||
wallet: PersistableWallet,
|
||||
backupState: BackupState,
|
||||
selectedTestChoices: TestChoices,
|
||||
onComplete: () -> Unit,
|
||||
) {
|
||||
Surface {
|
||||
Column {
|
||||
when (backupState.current.collectAsState().value) {
|
||||
BackupStage.EducationOverview -> EducationOverview(onNext = backupState::goNext)
|
||||
BackupStage.EducationRecoveryPhrase -> EducationRecoveryPhrase(onNext = backupState::goNext)
|
||||
BackupStage.Seed -> SeedPhrase(
|
||||
wallet, onNext = backupState::goNext,
|
||||
onCopyToClipboard = {
|
||||
// TODO [#49]
|
||||
}
|
||||
)
|
||||
BackupStage.Test -> Test(
|
||||
wallet,
|
||||
selectedTestChoices,
|
||||
onBack = backupState::goPrevious,
|
||||
onNext = backupState::goNext
|
||||
)
|
||||
BackupStage.Complete -> Complete(
|
||||
onComplete = onComplete,
|
||||
onBackToSeedPhrase = backupState::goToSeed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EducationOverview(onNext: () -> Unit) {
|
||||
Column {
|
||||
Header(stringResource(R.string.new_wallet_1_header))
|
||||
Body(stringResource(R.string.new_wallet_1_body_1))
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(MINIMAL_WEIGHT, true)
|
||||
)
|
||||
Body(stringResource(R.string.new_wallet_1_body_2))
|
||||
PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_1_button))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EducationRecoveryPhrase(onNext: () -> Unit) {
|
||||
Column {
|
||||
Header(stringResource(R.string.new_wallet_2_header))
|
||||
Body(stringResource(R.string.new_wallet_2_body_1))
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(MINIMAL_WEIGHT, true)
|
||||
)
|
||||
Body(stringResource(R.string.new_wallet_2_body_2))
|
||||
Card {
|
||||
Body(stringResource(R.string.new_wallet_2_body_3))
|
||||
}
|
||||
PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_2_button))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeedPhrase(persistableWallet: PersistableWallet, onNext: () -> Unit, onCopyToClipboard: () -> Unit) {
|
||||
Column {
|
||||
Header(stringResource(R.string.new_wallet_3_header))
|
||||
Body(stringResource(R.string.new_wallet_3_body_1))
|
||||
|
||||
ChipGrid(persistableWallet)
|
||||
|
||||
PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_3_button_finished))
|
||||
TertiaryButton(onClick = onCopyToClipboard, text = stringResource(R.string.new_wallet_3_button_copy))
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private val testIndices = listOf(Index(4), Index(9), Index(16), Index(20))
|
||||
|
||||
private data class TestChoice(val originalIndex: Index, val word: String)
|
||||
|
||||
@Composable
|
||||
private fun Test(
|
||||
wallet: PersistableWallet,
|
||||
selectedTestChoices: TestChoices,
|
||||
onBack: () -> Unit,
|
||||
onNext: () -> Unit
|
||||
) {
|
||||
val splitSeedPhrase = wallet.seedPhrase.split
|
||||
|
||||
val currentSelectedTestChoice = selectedTestChoices.current.collectAsState().value
|
||||
|
||||
when {
|
||||
currentSelectedTestChoice.size != testIndices.size -> {
|
||||
TestInProgress(splitSeedPhrase, selectedTestChoices, onBack)
|
||||
}
|
||||
currentSelectedTestChoice.all { splitSeedPhrase[it.key.value] == it.value } -> {
|
||||
// The user got the test correct
|
||||
onNext()
|
||||
}
|
||||
currentSelectedTestChoice.none { null == it.value } -> {
|
||||
TestFailure(onBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* A few implementation notes on the test:
|
||||
* - It is possible for the same word to appear twice in the word choices
|
||||
* - The test answer ordering is not randomized, to ensure it can never be in the correct order to start with
|
||||
*/
|
||||
@Composable
|
||||
private fun TestInProgress(
|
||||
splitSeedPhrase: List<String>,
|
||||
selectedTestChoices: TestChoices,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val testChoices = splitSeedPhrase
|
||||
.mapIndexed { index, word -> TestChoice(Index(index), word) }
|
||||
.filter { testIndices.contains(it.originalIndex) }
|
||||
.let {
|
||||
// Don't randomize; otherwise there's a chance they'll be in the right order to start with.
|
||||
@Suppress("MagicNumber")
|
||||
listOf(it[1], it[0], it[3], it[2])
|
||||
}
|
||||
val currentSelectedTestChoice = selectedTestChoices.current.collectAsState().value
|
||||
Column {
|
||||
// This button doesn't match the design; just providing the navigation hook for now
|
||||
NavigationButton(onClick = onBack, text = stringResource(R.string.new_wallet_4_button_back))
|
||||
|
||||
Header(stringResource(R.string.new_wallet_4_header_verify))
|
||||
// Body(stringResource(R.string.new_wallet_4_body_verify))
|
||||
|
||||
Column {
|
||||
splitSeedPhrase.chunked(CHIP_GRID_ROW_SIZE).forEachIndexed { chunkIndex, chunk ->
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
chunk.forEachIndexed { subIndex, word ->
|
||||
val currentIndex = Index(chunkIndex * CHIP_GRID_ROW_SIZE + subIndex)
|
||||
|
||||
if (testIndices.contains(currentIndex)) {
|
||||
ChipDropDown(
|
||||
currentIndex,
|
||||
dropdownText = currentSelectedTestChoice[currentIndex]
|
||||
?: "",
|
||||
choices = testChoices.map { it.word },
|
||||
modifier = Modifier
|
||||
.weight(MINIMAL_WEIGHT)
|
||||
.testTag(BackupTags.DROPDOWN_CHIP)
|
||||
) {
|
||||
selectedTestChoices.set(
|
||||
HashMap(currentSelectedTestChoice).apply {
|
||||
this[currentIndex] = testChoices[it.value].word
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Chip(
|
||||
index = Index(chunkIndex * CHIP_GRID_ROW_SIZE + subIndex),
|
||||
text = word,
|
||||
modifier = Modifier.weight(MINIMAL_WEIGHT)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TestFailure(onBackToSeedPhrase: () -> Unit) {
|
||||
Column {
|
||||
// This button doesn't match the design; just providing the navigation hook for now
|
||||
NavigationButton(onClick = onBackToSeedPhrase, text = stringResource(R.string.new_wallet_4_button_back))
|
||||
|
||||
Header(stringResource(R.string.new_wallet_4_header_ouch))
|
||||
|
||||
Box(Modifier.fillMaxHeight(MINIMAL_WEIGHT))
|
||||
|
||||
Body(stringResource(R.string.new_wallet_4_body_ouch_retry))
|
||||
|
||||
PrimaryButton(onClick = onBackToSeedPhrase, text = stringResource(R.string.new_wallet_4_button_retry))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Complete(onComplete: () -> Unit, onBackToSeedPhrase: () -> Unit) {
|
||||
Column {
|
||||
Header(stringResource(R.string.new_wallet_5_header))
|
||||
Body(stringResource(R.string.new_wallet_5_body))
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(MINIMAL_WEIGHT, true)
|
||||
)
|
||||
PrimaryButton(onClick = onComplete, text = stringResource(R.string.new_wallet_5_button_finished))
|
||||
TertiaryButton(onClick = onBackToSeedPhrase, text = stringResource(R.string.new_wallet_5_button_back))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
package cash.z.ecc.ui.screen.backup.view
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.DropdownMenu
|
||||
import androidx.compose.material.DropdownMenuItem
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cash.z.ecc.ui.screen.backup.BackupTags
|
||||
import cash.z.ecc.ui.screen.onboarding.model.Index
|
||||
import cash.z.ecc.ui.theme.MINIMAL_WEIGHT
|
||||
import cash.z.ecc.ui.theme.ZcashTheme
|
||||
|
||||
/**
|
||||
* @param chipIndex The index of the chip, which is displayed to the user.
|
||||
* @param dropdownText Text to display when the drop down is not open.
|
||||
* @param choices Item choices to display in the open drop down menu. Positional index is important.
|
||||
* @param onChoiceSelected Callback with the positional index of the item the user selected from [choices].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun ChipDropDown(
|
||||
chipIndex: Index,
|
||||
dropdownText: String,
|
||||
choices: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
onChoiceSelected: (Index) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
modifier = modifier.then(Modifier.padding(4.dp)),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
elevation = 8.dp,
|
||||
onClick = { expanded = !expanded }
|
||||
) {
|
||||
Row(modifier = Modifier.padding(8.dp)) {
|
||||
Text(
|
||||
text = (chipIndex.value + 1).toString(),
|
||||
style = ZcashTheme.typography.chipIndex,
|
||||
color = ZcashTheme.colors.chipIndex,
|
||||
)
|
||||
Spacer(modifier = Modifier.padding(horizontal = 2.dp, vertical = 0.dp))
|
||||
Text(dropdownText)
|
||||
Spacer(modifier = modifier.fillMaxWidth(MINIMAL_WEIGHT))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
val dropdownModifier = if (expanded) {
|
||||
Modifier.testTag(BackupTags.DROPDOWN_MENU)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = dropdownModifier
|
||||
) {
|
||||
choices.forEachIndexed { index, label ->
|
||||
DropdownMenuItem(onClick = {
|
||||
expanded = false
|
||||
onChoiceSelected(Index(index))
|
||||
}) {
|
||||
Text(text = label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
package cash.z.ecc.ui.screen.backup.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cash.z.ecc.android.sdk.ext.collectWith
|
||||
import cash.z.ecc.ui.screen.backup.model.BackupStage
|
||||
import cash.z.ecc.ui.screen.backup.state.BackupState
|
||||
import cash.z.ecc.ui.screen.backup.state.TestChoices
|
||||
import cash.z.ecc.ui.screen.onboarding.model.Index
|
||||
|
||||
class BackupViewModel(application: Application, savedStateHandle: SavedStateHandle) : AndroidViewModel(application) {
|
||||
val backupState: BackupState = run {
|
||||
val initialValue = if (savedStateHandle.contains(KEY_STAGE)) {
|
||||
savedStateHandle.get<BackupStage>(KEY_STAGE)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (null == initialValue) {
|
||||
BackupState()
|
||||
} else {
|
||||
BackupState(initialValue)
|
||||
}
|
||||
}
|
||||
|
||||
val testChoices: TestChoices = run {
|
||||
val initialValue = if (savedStateHandle.contains(KEY_TEST_CHOICES)) {
|
||||
savedStateHandle.get<HashMap<Index, String?>>(KEY_TEST_CHOICES)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (null == initialValue) {
|
||||
TestChoices()
|
||||
} else {
|
||||
TestChoices(initialValue)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// viewModelScope is constructed with Dispatchers.Main.immediate, so this will
|
||||
// update the save state as soon as a change occurs.
|
||||
backupState.current.collectWith(viewModelScope) {
|
||||
savedStateHandle.set(KEY_STAGE, it)
|
||||
}
|
||||
|
||||
testChoices.current.collectWith(viewModelScope) {
|
||||
// copy as explicit HashMap, since HashMap can be stored in a Bundle
|
||||
savedStateHandle.set(KEY_TEST_CHOICES, HashMap(it))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_STAGE = "stage" // $NON-NLS
|
||||
private const val KEY_TEST_CHOICES = "test_choices" // $NON-NLS
|
||||
}
|
||||
}
|
|
@ -17,7 +17,7 @@ import cash.z.ecc.ui.theme.ZcashTheme
|
|||
@Composable
|
||||
fun ComposablePreview() {
|
||||
ZcashTheme(darkTheme = false) {
|
||||
Chip(Index(1), "edict")
|
||||
Chip(Index(0), "edict")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -25,16 +25,17 @@ fun ComposablePreview() {
|
|||
fun Chip(
|
||||
index: Index,
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
modifier = modifier.then(Modifier.padding(4.dp)),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
elevation = 8.dp,
|
||||
) {
|
||||
Row(modifier = Modifier.padding(8.dp)) {
|
||||
Text(
|
||||
text = index.value.toString(),
|
||||
text = (index.value + 1).toString(),
|
||||
style = ZcashTheme.typography.chipIndex,
|
||||
color = ZcashTheme.colors.chipIndex,
|
||||
)
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
package cash.z.ecc.ui.screen.common
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import cash.z.ecc.sdk.model.PersistableWallet
|
||||
import cash.z.ecc.ui.screen.onboarding.model.Index
|
||||
import cash.z.ecc.ui.theme.MINIMAL_WEIGHT
|
||||
|
||||
const val CHIP_GRID_ROW_SIZE = 3
|
||||
|
||||
@Composable
|
||||
fun ChipGrid(persistableWallet: PersistableWallet) {
|
||||
Column {
|
||||
persistableWallet.seedPhrase.split.chunked(CHIP_GRID_ROW_SIZE).forEachIndexed { chunkIndex, chunk ->
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
chunk.forEachIndexed { subIndex, word ->
|
||||
Chip(
|
||||
index = Index(chunkIndex * CHIP_GRID_ROW_SIZE + subIndex),
|
||||
text = word,
|
||||
modifier = Modifier.weight(MINIMAL_WEIGHT)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,18 +5,20 @@ import androidx.compose.material.Surface
|
|||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import cash.z.ecc.sdk.fixture.PersistableWalletFixture
|
||||
import cash.z.ecc.sdk.model.PersistableWallet
|
||||
import cash.z.ecc.ui.theme.ZcashTheme
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ComposablePreview() {
|
||||
ZcashTheme(darkTheme = true) {
|
||||
Home()
|
||||
Home(PersistableWalletFixture.new())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Home() {
|
||||
fun Home(@Suppress("UNUSED_PARAMETER") persistableWallet: PersistableWallet) {
|
||||
Surface {
|
||||
Column {
|
||||
// Placeholder
|
||||
|
|
|
@ -3,72 +3,118 @@ package cash.z.ecc.ui.screen.home.viewmodel
|
|||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.sdk.SynchronizerCompanion
|
||||
import cash.z.ecc.sdk.model.PersistableWallet
|
||||
import cash.z.ecc.ui.common.ANDROID_STATE_FLOW_TIMEOUT_MILLIS
|
||||
import cash.z.ecc.ui.preference.EncryptedPreferenceKeys
|
||||
import cash.z.ecc.ui.preference.EncryptedPreferenceSingleton
|
||||
import cash.z.ecc.ui.preference.StandardPreferenceKeys
|
||||
import cash.z.ecc.ui.preference.StandardPreferenceSingleton
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
// To make this more multiplatform compatible, we need to remove the dependency on Context
|
||||
// for loading the preferences.
|
||||
class WalletViewModel(application: Application) : AndroidViewModel(application) {
|
||||
/*
|
||||
* Using the Mutex may be overkill, but it ensures that if multiple calls are accidentally made
|
||||
* that they have a consistent ordering.
|
||||
*/
|
||||
private val persistWalletMutex = Mutex()
|
||||
|
||||
/**
|
||||
* A flow of the user's stored wallet. Null indicates that no wallet has been stored.
|
||||
*/
|
||||
/*
|
||||
* This is exposed, because loading the value here is faster than loading the entire Zcash SDK.
|
||||
*
|
||||
* This allows the UI to load the first launch onboarding experience a few hundred milliseconds
|
||||
* faster.
|
||||
*/
|
||||
val persistableWallet = flow {
|
||||
private val persistableWalletFlow = flow {
|
||||
// EncryptedPreferenceSingleton.getInstance() is a suspending function, which is why we need
|
||||
// the flow builder to provide a coroutine context.
|
||||
val encryptedPreferenceProvider = EncryptedPreferenceSingleton.getInstance(application)
|
||||
|
||||
emitAll(EncryptedPreferenceKeys.PERSISTABLE_WALLET.observe(encryptedPreferenceProvider))
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A flow of the Zcash SDK initialized with the user's stored wallet. Null indicates that no
|
||||
* wallet has been stored.
|
||||
* A flow of whether a backup of the user's wallet has been performed.
|
||||
*/
|
||||
// Note: in the future we might want to convert this to emitting a sealed class with states like:
|
||||
// - No wallet
|
||||
// - Current wallet
|
||||
// - Error loading wallet
|
||||
val synchronizer = persistableWallet.map { persistableWallet ->
|
||||
persistableWallet?.let { SynchronizerCompanion.load(application, persistableWallet) }
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
|
||||
null
|
||||
)
|
||||
private val isBackupCompleteFlow = flow {
|
||||
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
|
||||
emitAll(StandardPreferenceKeys.IS_USER_BACKUP_COMPLETE.observe(preferenceProvider))
|
||||
}
|
||||
|
||||
val state: StateFlow<WalletState> = persistableWalletFlow
|
||||
.combine(isBackupCompleteFlow) { persistableWallet: PersistableWallet?, isBackupComplete: Boolean ->
|
||||
if (null == persistableWallet) {
|
||||
WalletState.NoWallet
|
||||
} else if (!isBackupComplete) {
|
||||
WalletState.NeedsBackup(persistableWallet)
|
||||
} else {
|
||||
WalletState.Ready(persistableWallet, SynchronizerCompanion.load(application, persistableWallet))
|
||||
}
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
|
||||
WalletState.Loading
|
||||
)
|
||||
|
||||
/**
|
||||
* Persists a wallet asynchronously. Clients observe either [persistableWallet] or [synchronizer]
|
||||
* to see the side effects.
|
||||
*
|
||||
* This method does not prevent multiple calls, so clients should be careful not to call this
|
||||
* method multiple times in rapid succession. While the persistableWallet write is atomic,
|
||||
* the ordering of the writes is not specified. If the same persistableWallet is passed in,
|
||||
* then there's no problem. But if different persistableWallets are passed in, then which one
|
||||
* actually gets written is non-deterministic.
|
||||
* to see the side effects. This would be used for a user restoring a wallet from a backup.
|
||||
*/
|
||||
fun persistWallet(persistableWallet: PersistableWallet) {
|
||||
viewModelScope.launch {
|
||||
val preferenceProvider = EncryptedPreferenceSingleton.getInstance(getApplication())
|
||||
EncryptedPreferenceKeys.PERSISTABLE_WALLET.putValue(preferenceProvider, persistableWallet)
|
||||
persistWalletMutex.withLock {
|
||||
EncryptedPreferenceKeys.PERSISTABLE_WALLET.putValue(preferenceProvider, persistableWallet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a wallet asynchronously and then persists it. Clients observe
|
||||
* [state] to see the side effects. This would be used for a user creating a new wallet.
|
||||
*/
|
||||
/*
|
||||
* Although waiting for the wallet to be written and then read back is slower, it is probably
|
||||
* safer because it 1. guarantees the wallet is written to disk and 2. has a single source of truth.
|
||||
*/
|
||||
fun createAndPersistWallet() {
|
||||
val application = getApplication<Application>()
|
||||
|
||||
viewModelScope.launch {
|
||||
val newWallet = PersistableWallet.new(application)
|
||||
persistWallet(newWallet)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously notes that the user has completed the backup steps, which means the wallet
|
||||
* is ready to use. Clients observe [state] to see the side effects. This would be used
|
||||
* for a user creating a new wallet.
|
||||
*/
|
||||
fun persistBackupComplete() {
|
||||
val application = getApplication<Application>()
|
||||
|
||||
viewModelScope.launch {
|
||||
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
|
||||
StandardPreferenceKeys.IS_USER_BACKUP_COMPLETE.putValue(preferenceProvider, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the state of the wallet
|
||||
*/
|
||||
sealed class WalletState {
|
||||
object Loading : WalletState()
|
||||
object NoWallet : WalletState()
|
||||
class NeedsBackup(val persistableWallet: PersistableWallet) : WalletState()
|
||||
class Ready(val persistableWallet: PersistableWallet, val synchronizer: Synchronizer) : WalletState()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
<resources>
|
||||
<string name="new_wallet_1_header">First things first</string>
|
||||
<string name="new_wallet_1_body_1">It is important to understand that you are in charge here. Great, right? YOU get to be the bank!</string>
|
||||
<string name="new_wallet_1_body_2">But it also means that YOU are the customer, and you need to be self-reliant.\n\nSo how do you recover funds that you‘ve hidden on a complete decentralized and private block-chain?</string>
|
||||
<string name="new_wallet_1_button">By understanding and preparing</string>
|
||||
|
||||
<string name="new_wallet_2_header">A Recovery Phrase</string>
|
||||
<string name="new_wallet_2_body_1">A recovery phrase is a series of 24 words in a specific order. This word combination is unique to your wallet and creates all of your different addresses.</string>
|
||||
<string name="new_wallet_2_body_2">You can restore your wallet with these words when needed.</string>
|
||||
<string name="new_wallet_2_body_3">So can anyone else, so keep them to yourself. It‘s a SECRET recovery phrase. If someone asks you for it, they are probably up to no good.</string>
|
||||
<string name="new_wallet_2_button">Got it, let‘s back them up</string>
|
||||
|
||||
<string name="new_wallet_3_header">Your Secret Recovery Phrase</string>
|
||||
<string name="new_wallet_3_body_1">These words represent your funds and the security used to protect them.\n\nBack them up now! There will be a test.</string>
|
||||
<string name="new_wallet_3_button_finished">Finished!</string>
|
||||
<string name="new_wallet_3_button_copy">Copy to buffer</string>
|
||||
|
||||
<string name="new_wallet_4_header_verify">Verify Your Backup</string>
|
||||
<string name="new_wallet_4_header_ouch">Ouch, sorry, no.</string>
|
||||
<string name="new_wallet_4_body_verify">Drag the words below to match your backed-up copy.</string>
|
||||
<string name="new_wallet_4_body_ouch">Your placed words did not match your secret recovery phrase.</string>
|
||||
<string name="new_wallet_4_body_ouch_retry">Your placed words did not match your secret recovery phrase.\n\nRemember, you can‘t recover your funds if you lose (or incorrectly save) these 24 words.</string>
|
||||
<string name="new_wallet_4_button_retry">I‘m ready to try again</string>
|
||||
<string name="new_wallet_4_button_back">Back</string>
|
||||
|
||||
<string name="new_wallet_5_header">Success!</string>
|
||||
<string name="new_wallet_5_body">Place that backup somewhere safe and venture forth in security.</string>
|
||||
<string name="new_wallet_5_button_finished">Take me to my wallet!</string>
|
||||
<string name="new_wallet_5_button_back">Show me my phrase again</string>
|
||||
</resources>
|
Loading…
Reference in New Issue