[#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:
Carter Jernigan 2021-11-17 15:19:49 -05:00 committed by GitHub
parent ec983f1f8f
commit 9db77e2afe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1202 additions and 76 deletions

View File

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

View File

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

View File

@ -1,3 +1,3 @@
<resources>
<string name="app_name">Demo App</string>
<string name="app_name">Zcash Wallet</string>
</resources>

View File

@ -1,4 +0,0 @@
<resources>
<string name="app_name">Mainnet Demo</string>
<string name="network_name">Mainnet</string>
</resources>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 youve 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. Its 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, lets 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 cant recover your funds if you lose (or incorrectly save) these 24 words.</string>
<string name="new_wallet_4_button_retry">Im 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>