secant-android-wallet/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/WalletViewModel.kt

514 lines
19 KiB
Kotlin

package co.electriccoin.zcash.ui.common.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toSeed
import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.WalletCoordinator
import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.FiatCurrency
import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.PersistableWallet
import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.WalletAddresses
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.sdk.extension.defaultForNetwork
import cash.z.ecc.sdk.type.fromResources
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.zcash.global.getInstance
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.common.compose.BalanceState
import co.electriccoin.zcash.ui.common.extension.throttle
import co.electriccoin.zcash.ui.common.model.OnboardingState
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.model.hasChangePending
import co.electriccoin.zcash.ui.common.model.spendableBalance
import co.electriccoin.zcash.ui.common.model.totalBalance
import co.electriccoin.zcash.ui.preference.EncryptedPreferenceKeys
import co.electriccoin.zcash.ui.preference.EncryptedPreferenceSingleton
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import co.electriccoin.zcash.ui.preference.StandardPreferenceSingleton
import co.electriccoin.zcash.ui.screen.account.ext.TransactionOverviewExt
import co.electriccoin.zcash.ui.screen.account.ext.getSortHeight
import co.electriccoin.zcash.ui.screen.account.state.TransactionHistorySyncState
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.seconds
// To make this more multiplatform compatible, we need to remove the dependency on Context
// for loading the preferences.
// TODO [#292]: Should be moved to SDK-EXT-UI module.
// TODO [#292]: https://github.com/Electric-Coin-Company/zashi-android/issues/292
class WalletViewModel(application: Application) : AndroidViewModel(application) {
private val walletCoordinator = WalletCoordinator.getInstance(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()
/**
* Synchronizer that is retained long enough to survive configuration changes.
*/
val synchronizer =
walletCoordinator.synchronizer.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
/**
* A flow of the user's preferred fiat currency.
*/
val preferredFiatCurrency: StateFlow<FiatCurrency?> =
flow<FiatCurrency?> {
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
emitAll(StandardPreferenceKeys.PREFERRED_FIAT_CURRENCY.observe(preferenceProvider))
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
/**
* A flow of the wallet block synchronization state.
*/
val walletRestoringState: StateFlow<WalletRestoringState> =
flow {
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
emitAll(
StandardPreferenceKeys.WALLET_RESTORING_STATE.observe(preferenceProvider).map { persistedNumber ->
WalletRestoringState.fromNumber(persistedNumber)
}
)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
WalletRestoringState.NONE
)
/**
* A flow of the wallet onboarding state.
*/
private val onboardingState =
flow {
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
emitAll(
StandardPreferenceKeys.ONBOARDING_STATE.observe(preferenceProvider).map { persistedNumber ->
OnboardingState.fromNumber(persistedNumber)
}
)
}
val secretState: StateFlow<SecretState> =
combine(
walletCoordinator.persistableWallet,
onboardingState
) { persistableWallet: PersistableWallet?, onboardingState: OnboardingState ->
when {
onboardingState == OnboardingState.NONE -> SecretState.None
onboardingState == OnboardingState.NEEDS_WARN -> SecretState.NeedsWarning
onboardingState == OnboardingState.NEEDS_BACKUP && persistableWallet != null -> {
SecretState.NeedsBackup(persistableWallet)
}
onboardingState == OnboardingState.READY && persistableWallet != null -> {
SecretState.Ready(persistableWallet)
}
else -> SecretState.None
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
SecretState.Loading
)
// This needs to be refactored once we support pin lock
val spendingKey =
secretState
.filterIsInstance<SecretState.Ready>()
.map { it.persistableWallet }
.map {
val bip39Seed =
withContext(Dispatchers.IO) {
Mnemonics.MnemonicCode(it.seedPhrase.joinToString()).toSeed()
}
DerivationTool.getInstance().deriveUnifiedSpendingKey(
seed = bip39Seed,
network = it.network,
account = Account.DEFAULT
)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
@OptIn(ExperimentalCoroutinesApi::class)
val walletSnapshot: StateFlow<WalletSnapshot?> =
synchronizer
.flatMapLatest {
if (null == it) {
flowOf(null)
} else {
it.toWalletSnapshot()
}
}
.throttle(1.seconds)
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
val addresses: StateFlow<WalletAddresses?> =
synchronizer
.filterNotNull()
.map {
WalletAddresses.new(it)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
@OptIn(ExperimentalCoroutinesApi::class)
val transactionHistoryState =
synchronizer
.filterNotNull()
.flatMapLatest { synchronizer ->
combine(
synchronizer.transactions,
synchronizer.status,
synchronizer.networkHeight.filterNotNull()
) {
transactions: List<TransactionOverview>,
status: Synchronizer.Status,
networkHeight: BlockHeight ->
val enhancedTransactions =
transactions
.sortedByDescending {
it.getSortHeight(networkHeight)
}
.map {
if (it.isSentTransaction) {
TransactionOverviewExt(it, synchronizer.getRecipients(it).firstOrNull())
} else {
TransactionOverviewExt(it, null)
}
}
if (status.isSyncing()) {
TransactionHistorySyncState.Syncing(enhancedTransactions.toPersistentList())
} else {
TransactionHistorySyncState.Done(enhancedTransactions.toPersistentList())
}
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = TransactionHistorySyncState.Loading
)
/**
* A flow of the wallet balances state used for the UI layer. It's computed form [WalletSnapshot]'s properties
* and provides the result [BalanceState] UI state.
*/
val balanceState: StateFlow<BalanceState> =
walletSnapshot
.filterNotNull()
.map { snapshot ->
when {
// Show the loader only under these conditions:
// - Available balance is currently zero
// - Wallet has some ChangePending in progress
// - And Total balance is non-zero
(
snapshot.spendableBalance().value == 0L &&
snapshot.hasChangePending() &&
snapshot.totalBalance().value > 0L
) -> {
BalanceState.Loading(
totalBalance = snapshot.totalBalance()
)
}
else -> {
BalanceState.Available(
totalBalance = snapshot.totalBalance(),
spendableBalance = snapshot.spendableBalance()
)
}
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
BalanceState.None
)
/**
* Creates a wallet asynchronously and then persists it. Clients observe
* [secretState] to see the side effects. This would be used for a user creating a new wallet.
*/
fun persistNewWallet() {
/*
* 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.
*/
val application = getApplication<Application>()
viewModelScope.launch {
val zcashNetwork = ZcashNetwork.fromResources(application)
val newWallet =
PersistableWallet.new(
application = application,
zcashNetwork = zcashNetwork,
endpoint = LightWalletEndpoint.defaultForNetwork(zcashNetwork),
walletInitMode = WalletInitMode.NewWallet
)
persistWallet(newWallet)
}
}
/**
* Persists a wallet asynchronously. Clients observe [secretState]
* to see the side effects. This would be used for a user restoring a wallet from a backup.
*/
fun persistExistingWallet(persistableWallet: PersistableWallet) {
persistWallet(persistableWallet)
}
/**
* Persists a wallet asynchronously. Clients observe [secretState] to see the side effects.
*/
private fun persistWallet(persistableWallet: PersistableWallet) {
val application = getApplication<Application>()
viewModelScope.launch {
val preferenceProvider = EncryptedPreferenceSingleton.getInstance(application)
persistWalletMutex.withLock {
EncryptedPreferenceKeys.PERSISTABLE_WALLET.putValue(preferenceProvider, persistableWallet)
}
}
}
/**
* Asynchronously notes that the user has completed the backup steps, which means the wallet
* is ready to use. Clients observe [secretState] to see the side effects. This would be used
* for a user creating a new wallet.
*/
fun persistOnboardingState(onboardingState: OnboardingState) {
val application = getApplication<Application>()
viewModelScope.launch {
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
// Use the Mutex here to avoid timing issues. During wallet restore, persistOnboardingState()
// is called prior to persistExistingWallet(). Although persistOnboardingState() should
// complete quickly, it isn't guaranteed to complete before persistExistingWallet()
// unless a mutex is used here.
persistWalletMutex.withLock {
StandardPreferenceKeys.ONBOARDING_STATE.putValue(preferenceProvider, onboardingState.toNumber())
}
}
}
/**
* Asynchronously notes that the wallet has completed the initial wallet restoring block synchronization run.
*
* Note that in the current SDK implementation, we don't have any information about the block synchronization
* state from the SDK, and thus, we need to note the wallet restoring state here on the client side.
*/
fun persistWalletRestoringState(walletRestoringState: WalletRestoringState) {
val application = getApplication<Application>()
viewModelScope.launch {
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
StandardPreferenceKeys.WALLET_RESTORING_STATE.putValue(preferenceProvider, walletRestoringState.toNumber())
}
}
/**
* This method only has an effect if the synchronizer currently is loaded.
*/
fun rescanBlockchain() {
viewModelScope.launch {
walletCoordinator.rescanBlockchain()
persistWalletRestoringState(WalletRestoringState.RESTORING)
}
}
/**
* This asynchronously resets the SDK state. This is non-destructive, as SDK state can be rederived.
*
* This could be used as a troubleshooting step in debugging.
*/
fun resetSdk() {
walletCoordinator.resetSdk()
}
/**
* This safely and asynchronously stops [Synchronizer].
*/
fun closeSynchronizer() {
val synchronizer = synchronizer.value
if (null != synchronizer) {
viewModelScope.launch {
(synchronizer as SdkSynchronizer).close()
}
}
}
}
/**
* Represents the state of the wallet secret.
*/
sealed class SecretState {
object Loading : SecretState()
object None : SecretState()
object NeedsWarning : SecretState()
class NeedsBackup(val persistableWallet: PersistableWallet) : SecretState()
class Ready(val persistableWallet: PersistableWallet) : SecretState()
}
// TODO [#529]: Localize Synchronizer Errors
// TODO [#529]: https://github.com/Electric-Coin-Company/zashi-android/issues/529
/**
* Represents all kind of Synchronizer errors
*/
sealed class SynchronizerError {
abstract fun getCauseMessage(): String?
class Critical(val error: Throwable?) : SynchronizerError() {
override fun getCauseMessage(): String? = error?.message
}
class Processor(val error: Throwable?) : SynchronizerError() {
override fun getCauseMessage(): String? = error?.message
}
class Submission(val error: Throwable?) : SynchronizerError() {
override fun getCauseMessage(): String? = error?.message
}
class Setup(val error: Throwable?) : SynchronizerError() {
override fun getCauseMessage(): String? = error?.message
}
class Chain(val x: BlockHeight, val y: BlockHeight) : SynchronizerError() {
override fun getCauseMessage(): String = "$x, $y"
}
}
private fun Synchronizer.toCommonError(): Flow<SynchronizerError?> =
callbackFlow {
// just for initial default value emit
trySend(null)
onCriticalErrorHandler = {
Twig.error { "WALLET - Error Critical: $it" }
trySend(SynchronizerError.Critical(it))
false
}
onProcessorErrorHandler = {
Twig.error { "WALLET - Error Processor: $it" }
trySend(SynchronizerError.Processor(it))
false
}
onSubmissionErrorHandler = {
Twig.error { "WALLET - Error Submission: $it" }
trySend(SynchronizerError.Submission(it))
false
}
onSetupErrorHandler = {
Twig.error { "WALLET - Error Setup: $it" }
trySend(SynchronizerError.Setup(it))
false
}
onChainErrorHandler = { x, y ->
Twig.error { "WALLET - Error Chain: $x, $y" }
trySend(SynchronizerError.Chain(x, y))
}
awaitClose {
// nothing to close here
}
}
// No good way around needing magic numbers for the indices
@Suppress("MagicNumber")
private fun Synchronizer.toWalletSnapshot() =
combine(
// 0
status,
// 1
processorInfo,
// 2
orchardBalances,
// 3
saplingBalances,
// 4
transparentBalance,
// 5
progress,
// 6
toCommonError()
) { flows ->
val orchardBalance = flows[2] as WalletBalance?
val saplingBalance = flows[3] as WalletBalance?
val transparentBalance = flows[4] as Zatoshi?
val progressPercentDecimal = flows[5] as PercentDecimal
WalletSnapshot(
flows[0] as Synchronizer.Status,
flows[1] as CompactBlockProcessor.ProcessorInfo,
orchardBalance ?: WalletBalance(Zatoshi(0), Zatoshi(0), Zatoshi(0)),
saplingBalance ?: WalletBalance(Zatoshi(0), Zatoshi(0), Zatoshi(0)),
transparentBalance ?: Zatoshi(0),
progressPercentDecimal,
flows[6] as SynchronizerError?
)
}
fun Synchronizer.Status.isSyncing() = this == Synchronizer.Status.SYNCING
fun Synchronizer.Status.isSynced() = this == Synchronizer.Status.SYNCED