secant-android-wallet/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/viewmodel/WalletViewModel.kt

310 lines
12 KiB
Kotlin

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.Initializer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.db.entity.PendingTransaction
import cash.z.ecc.android.sdk.db.entity.Transaction
import cash.z.ecc.android.sdk.db.entity.isMined
import cash.z.ecc.android.sdk.db.entity.isSubmitSuccess
import cash.z.ecc.android.sdk.type.WalletBalance
import cash.z.ecc.android.sdk.type.ZcashNetwork
import cash.z.ecc.sdk.SynchronizerCompanion
import cash.z.ecc.sdk.model.PersistableWallet
import cash.z.ecc.sdk.model.WalletAddresses
import cash.z.ecc.sdk.type.fromResources
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 cash.z.ecc.ui.screen.home.model.WalletSnapshot
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapConcat
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()
private val synchronizerMutex = Mutex()
/**
* A flow of the user's stored wallet. Null indicates that no wallet has been stored.
*/
private val persistableWallet = 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))
}
/**
* A flow of whether a backup of the user's wallet has been performed.
*/
private val isBackupComplete = flow {
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
emitAll(StandardPreferenceKeys.IS_USER_BACKUP_COMPLETE.observe(preferenceProvider))
}
val secretState: StateFlow<SecretState> = persistableWallet
.combine(isBackupComplete) { persistableWallet: PersistableWallet?, isBackupComplete: Boolean ->
if (null == persistableWallet) {
SecretState.None
} else if (!isBackupComplete) {
SecretState.NeedsBackup(persistableWallet)
} else {
SecretState.Ready(persistableWallet)
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
SecretState.Loading
)
// This will likely move to an application global, so that it can be referenced by WorkManager
// for background synchronization
/**
* Synchronizer for the Zcash SDK. Note that the synchronizer loads as soon as a secret is stored,
* even if the backup of the secret has not occurred yet.
*/
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val synchronizer: StateFlow<Synchronizer?> = persistableWallet
.filterNotNull()
.flatMapConcat {
callbackFlow {
val synchronizer = synchronizerMutex.withLock {
val synchronizer = SynchronizerCompanion.load(application, it)
synchronizer.start(viewModelScope)
}
trySend(synchronizer)
awaitClose {
synchronizer.stop()
}
}
}.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
null
)
@OptIn(FlowPreview::class)
val walletSnapshot: StateFlow<WalletSnapshot?> = synchronizer
.filterNotNull()
.flatMapConcat { it.toWalletSnapshot() }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
null
)
// This is not the right API, because the transaction list could be very long and might need UI filtering
@OptIn(FlowPreview::class)
val transactionSnapshot: StateFlow<List<Transaction>> = synchronizer
.filterNotNull()
.flatMapConcat { it.toTransactions() }
.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
emptyList()
)
@OptIn(FlowPreview::class)
val addresses: StateFlow<WalletAddresses?> = secretState
.filterIsInstance<SecretState.Ready>()
.map { WalletAddresses.new(it.persistableWallet) }
.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
null
)
/**
* 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.
*/
/*
* 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 persistNewWallet() {
val application = getApplication<Application>()
viewModelScope.launch {
val newWallet = PersistableWallet.new(application)
persistExistingWallet(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) {
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 persistBackupComplete() {
val application = getApplication<Application>()
viewModelScope.launch {
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
// Use the Mutex here to avoid timing issues. During wallet restore, persistBackupComplete()
// is called prior to persistExistingWallet(). Although persistBackupComplete() should
// complete quickly, it isn't guaranteed to complete before persistExistingWallet()
// unless a mutex is used here.
persistWalletMutex.withLock {
StandardPreferenceKeys.IS_USER_BACKUP_COMPLETE.putValue(preferenceProvider, true)
}
}
}
/**
* This method only has an effect if the synchronizer currently is loaded.
*/
fun rescanBlockchain() {
viewModelScope.launch {
synchronizerMutex.withLock {
synchronizer.value?.let {
it.rewindToNearestHeight(it.latestBirthdayHeight, true)
}
}
}
}
/**
* This asynchronously wipes the wallet state.
*
* This method only has an effect if the synchronizer currently is loaded.
*/
fun wipeWallet() {
/*
* This implementation could perhaps be a little brittle due to needing to stop and start the
* synchronizer. If another client is interacting with the synchronizer at the same time,
* it isn't well defined exactly what the behavior should be.
*
* Possible enhancements to improve this:
* - Hide the synchronizer from clients; prefer to add additional APIs to WalletViewModel
* which delegate to the synchronizer
* - Add a private StateFlow to WalletViewModel to signal internal operations which should
* cancel the synchronizer for other observers. Modify synchronizer flow to use a combine
* operator to check the private stateflow. When initiating a wipe, set that private
* StateFlow to cancel other observers of the synchronizer.
*/
viewModelScope.launch {
synchronizerMutex.withLock {
synchronizer.value?.let {
// There is a minor race condition here. With the right timing, it is possible
// that the collection of the Synchronizer flow is canceled during an erase.
// In such a situation, the Synchronizer would be restarted at the end of
// this method even though it shouldn't. Overall it shouldn't be too harmful,
// since the viewModelScope would still eventually be canceled.
// By at least checking for referential equality at the end, we can reduce that
// timing gap.
val wasStarted = it.isStarted
if (wasStarted) {
it.stop()
}
Initializer.erase(
getApplication(),
ZcashNetwork.fromResources(getApplication())
)
if (wasStarted && synchronizer.value === it) {
it.start(viewModelScope)
}
}
}
}
}
}
/**
* Represents the state of the wallet secret.
*/
sealed class SecretState {
object Loading : SecretState()
object None : SecretState()
class NeedsBackup(val persistableWallet: PersistableWallet) : SecretState()
class Ready(val persistableWallet: PersistableWallet) : SecretState()
}
// No good way around needing magic numbers for the indices
@Suppress("MagicNumber")
private fun Synchronizer.toWalletSnapshot() =
combine(
status, // 0
processorInfo, // 1
orchardBalances, // 2
saplingBalances, // 3
transparentBalances, // 4
pendingTransactions.distinctUntilChanged() // 5
) { flows ->
val pendingCount = (flows[5] as List<*>)
.filterIsInstance(PendingTransaction::class.java)
.count {
it.isSubmitSuccess() && !it.isMined()
}
WalletSnapshot(
status = flows[0] as Synchronizer.Status,
processorInfo = flows[1] as CompactBlockProcessor.ProcessorInfo,
orchardBalance = flows[2] as WalletBalance,
saplingBalance = flows[3] as WalletBalance,
transparentBalance = flows[4] as WalletBalance,
pendingCount = pendingCount
)
}
private fun Synchronizer.toTransactions() =
combine(
clearedTransactions.distinctUntilChanged(),
pendingTransactions.distinctUntilChanged(),
sentTransactions.distinctUntilChanged(),
receivedTransactions.distinctUntilChanged(),
) { cleared, pending, sent, received ->
// TODO [#157]: Sort the transactions to show the most recent
buildList<Transaction> {
addAll(cleared)
addAll(pending)
addAll(sent)
addAll(received)
}
}