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

130 lines
5.3 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.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.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.
*/
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))
}
/**
* A flow of whether a backup of the user's wallet has been performed.
*/
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
)
/**
* 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 persistNewWallet() {
val application = getApplication<Application>()
viewModelScope.launch {
val newWallet = PersistableWallet.new(application)
persistExistingWallet(newWallet)
}
}
/**
* Persists a wallet asynchronously. Clients observe [state]
* 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 [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)
// 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)
}
}
}
}
/**
* 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()
}