[#191] Background sync
Preliminary and limited version of background sync, with the following limitations - Sync is always enabled - Default sync period is 24 hours and is not updated once the wallet is created This change refactors the synchronizer to a global singleton that both WorkManager and the UI can interact with.
This commit is contained in:
parent
b6ed705866
commit
52588e6bd3
|
@ -43,4 +43,4 @@ There are some app-wide resources that share a common namespace, and these shoul
|
||||||
* Notification Channels
|
* Notification Channels
|
||||||
* No notification channels are currently defined
|
* No notification channels are currently defined
|
||||||
* WorkManager Tags
|
* WorkManager Tags
|
||||||
* No WorkManager tags are currently defined
|
* "co.electriccoin.zcash.background_sync" is defined in `WorkIds.kt`
|
||||||
|
|
|
@ -85,6 +85,7 @@ ANDROIDX_TEST_JUNIT_VERSION=1.1.3
|
||||||
ANDROIDX_TEST_ORCHESTRATOR_VERSION=1.4.1
|
ANDROIDX_TEST_ORCHESTRATOR_VERSION=1.4.1
|
||||||
ANDROIDX_TEST_VERSION=1.4.1-alpha03
|
ANDROIDX_TEST_VERSION=1.4.1-alpha03
|
||||||
ANDROIDX_UI_AUTOMATOR_VERSION=2.2.0-alpha1
|
ANDROIDX_UI_AUTOMATOR_VERSION=2.2.0-alpha1
|
||||||
|
ANDROIDX_WORK_MANAGER_VERSION=2.7.1
|
||||||
CORE_LIBRARY_DESUGARING_VERSION=1.1.5
|
CORE_LIBRARY_DESUGARING_VERSION=1.1.5
|
||||||
GOOGLE_MATERIAL_VERSION=1.4.0
|
GOOGLE_MATERIAL_VERSION=1.4.0
|
||||||
JACOCO_VERSION=0.8.7
|
JACOCO_VERSION=0.8.7
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
package cash.z.ecc.sdk.type
|
package cash.z.ecc.sdk.type
|
||||||
|
|
||||||
import android.app.Application
|
import android.content.Context
|
||||||
import cash.z.ecc.android.sdk.type.ZcashNetwork
|
import cash.z.ecc.android.sdk.type.ZcashNetwork
|
||||||
import cash.z.ecc.sdk.ext.R
|
import cash.z.ecc.sdk.ext.R
|
||||||
|
|
||||||
|
@ -23,8 +23,8 @@ import cash.z.ecc.sdk.ext.R
|
||||||
* @return Zcash network determined from resources. A resource overlay of [R.bool.zcash_is_testnet]
|
* @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.
|
* can be used for different build variants to change the network type.
|
||||||
*/
|
*/
|
||||||
fun ZcashNetwork.Companion.fromResources(application: Application) =
|
fun ZcashNetwork.Companion.fromResources(context: Context) =
|
||||||
if (application.resources.getBoolean(R.bool.zcash_is_testnet)) {
|
if (context.resources.getBoolean(R.bool.zcash_is_testnet)) {
|
||||||
ZcashNetwork.Testnet
|
ZcashNetwork.Testnet
|
||||||
} else {
|
} else {
|
||||||
ZcashNetwork.Mainnet
|
ZcashNetwork.Mainnet
|
||||||
|
|
|
@ -99,6 +99,7 @@ dependencyResolutionManagement {
|
||||||
val androidxTestOrchestratorVersion = extra["ANDROIDX_TEST_ORCHESTRATOR_VERSION"].toString()
|
val androidxTestOrchestratorVersion = extra["ANDROIDX_TEST_ORCHESTRATOR_VERSION"].toString()
|
||||||
val androidxTestVersion = extra["ANDROIDX_TEST_VERSION"].toString()
|
val androidxTestVersion = extra["ANDROIDX_TEST_VERSION"].toString()
|
||||||
val androidxUiAutomatorVersion = extra["ANDROIDX_UI_AUTOMATOR_VERSION"].toString()
|
val androidxUiAutomatorVersion = extra["ANDROIDX_UI_AUTOMATOR_VERSION"].toString()
|
||||||
|
val androidxWorkManagerVersion = extra["ANDROIDX_WORK_MANAGER_VERSION"].toString()
|
||||||
val coreLibraryDesugaringVersion = extra["CORE_LIBRARY_DESUGARING_VERSION"].toString()
|
val coreLibraryDesugaringVersion = extra["CORE_LIBRARY_DESUGARING_VERSION"].toString()
|
||||||
val googleMaterialVersion = extra["GOOGLE_MATERIAL_VERSION"].toString()
|
val googleMaterialVersion = extra["GOOGLE_MATERIAL_VERSION"].toString()
|
||||||
val jacocoVersion = extra["JACOCO_VERSION"].toString()
|
val jacocoVersion = extra["JACOCO_VERSION"].toString()
|
||||||
|
@ -131,6 +132,7 @@ dependencyResolutionManagement {
|
||||||
alias("androidx-security-crypto").to("androidx.security:security-crypto-ktx:$androidxSecurityCryptoVersion")
|
alias("androidx-security-crypto").to("androidx.security:security-crypto-ktx:$androidxSecurityCryptoVersion")
|
||||||
alias("androidx-splash").to("androidx.core:core-splashscreen:$androidxSplashScreenVersion")
|
alias("androidx-splash").to("androidx.core:core-splashscreen:$androidxSplashScreenVersion")
|
||||||
alias("androidx-viewmodel-compose").to("androidx.lifecycle:lifecycle-viewmodel-compose:$androidxLifecycleVersion")
|
alias("androidx-viewmodel-compose").to("androidx.lifecycle:lifecycle-viewmodel-compose:$androidxLifecycleVersion")
|
||||||
|
alias("androidx-workmanager").to("androidx.work:work-runtime-ktx:$androidxWorkManagerVersion")
|
||||||
alias("desugaring").to("com.android.tools:desugar_jdk_libs:$coreLibraryDesugaringVersion")
|
alias("desugaring").to("com.android.tools:desugar_jdk_libs:$coreLibraryDesugaringVersion")
|
||||||
alias("google-material").to("com.google.android.material:material:$googleMaterialVersion")
|
alias("google-material").to("com.google.android.material:material:$googleMaterialVersion")
|
||||||
alias("kotlin-stdlib").to("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
|
alias("kotlin-stdlib").to("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
|
||||||
|
|
|
@ -47,6 +47,7 @@ dependencies {
|
||||||
implementation(libs.androidx.core)
|
implementation(libs.androidx.core)
|
||||||
implementation(libs.androidx.lifecycle.livedata)
|
implementation(libs.androidx.lifecycle.livedata)
|
||||||
implementation(libs.androidx.splash)
|
implementation(libs.androidx.splash)
|
||||||
|
implementation(libs.androidx.workmanager)
|
||||||
implementation(libs.bundles.androidx.compose)
|
implementation(libs.bundles.androidx.compose)
|
||||||
implementation(libs.google.material)
|
implementation(libs.google.material)
|
||||||
implementation(libs.kotlin.stdlib)
|
implementation(libs.kotlin.stdlib)
|
||||||
|
|
|
@ -0,0 +1,155 @@
|
||||||
|
package cash.z.ecc.global
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import cash.z.ecc.android.sdk.Initializer
|
||||||
|
import cash.z.ecc.android.sdk.Synchronizer
|
||||||
|
import cash.z.ecc.android.sdk.type.ZcashNetwork
|
||||||
|
import cash.z.ecc.sdk.SynchronizerCompanion
|
||||||
|
import cash.z.ecc.sdk.type.fromResources
|
||||||
|
import cash.z.ecc.ui.preference.EncryptedPreferenceKeys
|
||||||
|
import cash.z.ecc.ui.preference.EncryptedPreferenceSingleton
|
||||||
|
import cash.z.ecc.ui.util.LazyWithArgument
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.emitAll
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.flatMapConcat
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
class WalletCoordinator(context: Context) {
|
||||||
|
companion object {
|
||||||
|
private val lazy = LazyWithArgument<Context, WalletCoordinator> { WalletCoordinator(it) }
|
||||||
|
|
||||||
|
fun getInstance(context: Context) = lazy.getInstance(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val applicationContext = context.applicationContext
|
||||||
|
|
||||||
|
/*
|
||||||
|
* We want a global scope that is independent of the lifecycles of either
|
||||||
|
* WorkManager or the UI.
|
||||||
|
*/
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
private val walletScope = CoroutineScope(GlobalScope.coroutineContext + Dispatchers.Main)
|
||||||
|
|
||||||
|
private val synchronizerMutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A flow of the user's stored wallet. Null indicates that no wallet has been stored.
|
||||||
|
*/
|
||||||
|
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(applicationContext)
|
||||||
|
|
||||||
|
emitAll(EncryptedPreferenceKeys.PERSISTABLE_WALLET.observe(encryptedPreferenceProvider))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronizer for the Zcash SDK. Emits null until a wallet secret is persisted.
|
||||||
|
*
|
||||||
|
* Note that this synchronizer is closed as soon as it stops being collected. For UI use
|
||||||
|
* cases, see [WalletViewModel].
|
||||||
|
*/
|
||||||
|
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||||
|
val synchronizer: StateFlow<Synchronizer?> = persistableWallet
|
||||||
|
.filterNotNull()
|
||||||
|
.flatMapConcat {
|
||||||
|
callbackFlow {
|
||||||
|
val synchronizer = synchronizerMutex.withLock {
|
||||||
|
val synchronizer = SynchronizerCompanion.load(applicationContext, it)
|
||||||
|
|
||||||
|
synchronizer.start(walletScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
trySend(synchronizer)
|
||||||
|
awaitClose {
|
||||||
|
synchronizer.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.stateIn(
|
||||||
|
walletScope, SharingStarted.WhileSubscribed(),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rescans the blockchain.
|
||||||
|
*
|
||||||
|
* In order for a rescan to occur, the synchronizer must be loaded already
|
||||||
|
* which would happen if the UI is collecting it.
|
||||||
|
*
|
||||||
|
* @return True if the rewind was performed and false if the wipe was not performed.
|
||||||
|
*/
|
||||||
|
suspend fun rescanBlockchain(): Boolean {
|
||||||
|
synchronizerMutex.withLock {
|
||||||
|
synchronizer.value?.let {
|
||||||
|
it.rewindToNearestHeight(it.latestBirthdayHeight, true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipes the wallet.
|
||||||
|
*
|
||||||
|
* In order for a wipe to occur, the synchronizer must be loaded already
|
||||||
|
* which would happen if the UI is collecting it.
|
||||||
|
*
|
||||||
|
* @return True if the wipe was performed and false if the wipe was not performed.
|
||||||
|
*/
|
||||||
|
suspend fun wipeWallet(): Boolean {
|
||||||
|
/*
|
||||||
|
* 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 WalletCoordinator 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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. By checking for referential equality at
|
||||||
|
// the end, we can reduce that timing gap.
|
||||||
|
val wasStarted = it.isStarted
|
||||||
|
if (wasStarted) {
|
||||||
|
it.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
Initializer.erase(
|
||||||
|
applicationContext,
|
||||||
|
ZcashNetwork.fromResources(applicationContext)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (wasStarted && synchronizer.value === it) {
|
||||||
|
it.start(walletScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
package cash.z.ecc.ui.preference
|
package cash.z.ecc.ui.preference
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import cash.z.ecc.ui.util.Lazy
|
import cash.z.ecc.ui.util.SuspendingLazy
|
||||||
import co.electriccoin.zcash.preference.AndroidPreferenceProvider
|
import co.electriccoin.zcash.preference.AndroidPreferenceProvider
|
||||||
import co.electriccoin.zcash.preference.api.PreferenceProvider
|
import co.electriccoin.zcash.preference.api.PreferenceProvider
|
||||||
|
|
||||||
|
@ -9,7 +9,9 @@ object EncryptedPreferenceSingleton {
|
||||||
|
|
||||||
private const val PREF_FILENAME = "co.electriccoin.zcash.encrypted"
|
private const val PREF_FILENAME = "co.electriccoin.zcash.encrypted"
|
||||||
|
|
||||||
private val lazy = Lazy<Context, PreferenceProvider> { AndroidPreferenceProvider.newEncrypted(it, PREF_FILENAME) }
|
private val lazy = SuspendingLazy<Context, PreferenceProvider> {
|
||||||
|
AndroidPreferenceProvider.newEncrypted(it, PREF_FILENAME)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getInstance(context: Context) = lazy.getInstance(context)
|
suspend fun getInstance(context: Context) = lazy.getInstance(context)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package cash.z.ecc.ui.preference
|
package cash.z.ecc.ui.preference
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import cash.z.ecc.ui.util.Lazy
|
import cash.z.ecc.ui.util.SuspendingLazy
|
||||||
import co.electriccoin.zcash.preference.AndroidPreferenceProvider
|
import co.electriccoin.zcash.preference.AndroidPreferenceProvider
|
||||||
import co.electriccoin.zcash.preference.api.PreferenceProvider
|
import co.electriccoin.zcash.preference.api.PreferenceProvider
|
||||||
|
|
||||||
|
@ -9,7 +9,9 @@ object StandardPreferenceSingleton {
|
||||||
|
|
||||||
private const val PREF_FILENAME = "co.electriccoin.zcash"
|
private const val PREF_FILENAME = "co.electriccoin.zcash"
|
||||||
|
|
||||||
private val lazy = Lazy<Context, PreferenceProvider> { AndroidPreferenceProvider.newStandard(it, PREF_FILENAME) }
|
private val lazy = SuspendingLazy<Context, PreferenceProvider> {
|
||||||
|
AndroidPreferenceProvider.newStandard(it, PREF_FILENAME)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getInstance(context: Context) = lazy.getInstance(context)
|
suspend fun getInstance(context: Context) = lazy.getInstance(context)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@ package cash.z.ecc.ui.screen.home.viewmodel
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import cash.z.ecc.android.sdk.Initializer
|
|
||||||
import cash.z.ecc.android.sdk.Synchronizer
|
import cash.z.ecc.android.sdk.Synchronizer
|
||||||
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
|
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.PendingTransaction
|
||||||
|
@ -11,23 +10,19 @@ 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.isMined
|
||||||
import cash.z.ecc.android.sdk.db.entity.isSubmitSuccess
|
import cash.z.ecc.android.sdk.db.entity.isSubmitSuccess
|
||||||
import cash.z.ecc.android.sdk.type.WalletBalance
|
import cash.z.ecc.android.sdk.type.WalletBalance
|
||||||
import cash.z.ecc.android.sdk.type.ZcashNetwork
|
import cash.z.ecc.global.WalletCoordinator
|
||||||
import cash.z.ecc.sdk.SynchronizerCompanion
|
|
||||||
import cash.z.ecc.sdk.model.PersistableWallet
|
import cash.z.ecc.sdk.model.PersistableWallet
|
||||||
import cash.z.ecc.sdk.model.WalletAddresses
|
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.common.ANDROID_STATE_FLOW_TIMEOUT_MILLIS
|
||||||
import cash.z.ecc.ui.preference.EncryptedPreferenceKeys
|
import cash.z.ecc.ui.preference.EncryptedPreferenceKeys
|
||||||
import cash.z.ecc.ui.preference.EncryptedPreferenceSingleton
|
import cash.z.ecc.ui.preference.EncryptedPreferenceSingleton
|
||||||
import cash.z.ecc.ui.preference.StandardPreferenceKeys
|
import cash.z.ecc.ui.preference.StandardPreferenceKeys
|
||||||
import cash.z.ecc.ui.preference.StandardPreferenceSingleton
|
import cash.z.ecc.ui.preference.StandardPreferenceSingleton
|
||||||
import cash.z.ecc.ui.screen.home.model.WalletSnapshot
|
import cash.z.ecc.ui.screen.home.model.WalletSnapshot
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import cash.z.ecc.work.WorkIds
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.emitAll
|
import kotlinx.coroutines.flow.emitAll
|
||||||
|
@ -44,23 +39,22 @@ import kotlinx.coroutines.sync.withLock
|
||||||
// To make this more multiplatform compatible, we need to remove the dependency on Context
|
// To make this more multiplatform compatible, we need to remove the dependency on Context
|
||||||
// for loading the preferences.
|
// for loading the preferences.
|
||||||
class WalletViewModel(application: Application) : AndroidViewModel(application) {
|
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
|
* Using the Mutex may be overkill, but it ensures that if multiple calls are accidentally made
|
||||||
* that they have a consistent ordering.
|
* that they have a consistent ordering.
|
||||||
*/
|
*/
|
||||||
private val persistWalletMutex = Mutex()
|
private val persistWalletMutex = Mutex()
|
||||||
private val synchronizerMutex = Mutex()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A flow of the user's stored wallet. Null indicates that no wallet has been stored.
|
* Synchronizer that is retained long enough to survive configuration changes.
|
||||||
*/
|
*/
|
||||||
private val persistableWallet = flow {
|
val synchronizer = walletCoordinator.synchronizer.stateIn(
|
||||||
// EncryptedPreferenceSingleton.getInstance() is a suspending function, which is why we need
|
viewModelScope,
|
||||||
// the flow builder to provide a coroutine context.
|
SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
|
||||||
val encryptedPreferenceProvider = EncryptedPreferenceSingleton.getInstance(application)
|
null
|
||||||
|
)
|
||||||
emitAll(EncryptedPreferenceKeys.PERSISTABLE_WALLET.observe(encryptedPreferenceProvider))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A flow of whether a backup of the user's wallet has been performed.
|
* A flow of whether a backup of the user's wallet has been performed.
|
||||||
|
@ -70,7 +64,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
|
||||||
emitAll(StandardPreferenceKeys.IS_USER_BACKUP_COMPLETE.observe(preferenceProvider))
|
emitAll(StandardPreferenceKeys.IS_USER_BACKUP_COMPLETE.observe(preferenceProvider))
|
||||||
}
|
}
|
||||||
|
|
||||||
val secretState: StateFlow<SecretState> = persistableWallet
|
val secretState: StateFlow<SecretState> = walletCoordinator.persistableWallet
|
||||||
.combine(isBackupComplete) { persistableWallet: PersistableWallet?, isBackupComplete: Boolean ->
|
.combine(isBackupComplete) { persistableWallet: PersistableWallet?, isBackupComplete: Boolean ->
|
||||||
if (null == persistableWallet) {
|
if (null == persistableWallet) {
|
||||||
SecretState.None
|
SecretState.None
|
||||||
|
@ -85,33 +79,6 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
|
||||||
SecretState.Loading
|
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)
|
@OptIn(FlowPreview::class)
|
||||||
val walletSnapshot: StateFlow<WalletSnapshot?> = synchronizer
|
val walletSnapshot: StateFlow<WalletSnapshot?> = synchronizer
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
|
@ -170,6 +137,8 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
|
||||||
persistWalletMutex.withLock {
|
persistWalletMutex.withLock {
|
||||||
EncryptedPreferenceKeys.PERSISTABLE_WALLET.putValue(preferenceProvider, persistableWallet)
|
EncryptedPreferenceKeys.PERSISTABLE_WALLET.putValue(preferenceProvider, persistableWallet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WorkIds.enableBackgroundSynchronization(application)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -199,11 +168,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
|
||||||
*/
|
*/
|
||||||
fun rescanBlockchain() {
|
fun rescanBlockchain() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
synchronizerMutex.withLock {
|
walletCoordinator.rescanBlockchain()
|
||||||
synchronizer.value?.let {
|
|
||||||
it.rewindToNearestHeight(it.latestBirthdayHeight, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -213,45 +178,8 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
|
||||||
* This method only has an effect if the synchronizer currently is loaded.
|
* This method only has an effect if the synchronizer currently is loaded.
|
||||||
*/
|
*/
|
||||||
fun wipeWallet() {
|
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 {
|
viewModelScope.launch {
|
||||||
synchronizerMutex.withLock {
|
walletCoordinator.wipeWallet()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
package cash.z.ecc.ui.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements a lazy singleton pattern with an input argument.
|
||||||
|
*
|
||||||
|
* This class is thread-safe.
|
||||||
|
*/
|
||||||
|
class LazyWithArgument<in Input, out Output>(private val deferredCreator: ((Input) -> Output)) {
|
||||||
|
@Volatile
|
||||||
|
private var singletonInstance: Output? = null
|
||||||
|
|
||||||
|
private val intrinsicLock = Any()
|
||||||
|
|
||||||
|
fun getInstance(input: Input): Output {
|
||||||
|
/*
|
||||||
|
* Double-checked idiom for lazy initialization, Effective Java 2nd edition page 283.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var localSingletonInstance = singletonInstance
|
||||||
|
if (null == localSingletonInstance) {
|
||||||
|
synchronized(intrinsicLock) {
|
||||||
|
localSingletonInstance = singletonInstance
|
||||||
|
|
||||||
|
if (null == localSingletonInstance) {
|
||||||
|
localSingletonInstance = deferredCreator(input)
|
||||||
|
singletonInstance = localSingletonInstance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return localSingletonInstance!!
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,7 +8,7 @@ import kotlinx.coroutines.sync.withLock
|
||||||
*
|
*
|
||||||
* This class is thread-safe.
|
* This class is thread-safe.
|
||||||
*/
|
*/
|
||||||
class Lazy<in Input, out Output>(private val deferredCreator: suspend ((Input) -> Output)) {
|
class SuspendingLazy<in Input, out Output>(private val deferredCreator: suspend ((Input) -> Output)) {
|
||||||
private var singletonInstance: Output? = null
|
private var singletonInstance: Output? = null
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
|
@ -0,0 +1,65 @@
|
||||||
|
package cash.z.ecc.work
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequest
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import cash.z.ecc.android.sdk.Synchronizer
|
||||||
|
import cash.z.ecc.global.WalletCoordinator
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.takeWhile
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.toJavaDuration
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
class SyncWorker(context: Context, workerParameters: WorkerParameters) : CoroutineWorker(context, workerParameters) {
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
// Enhancements to this implementation would be:
|
||||||
|
// - Quit early if the synchronizer is null after a timeout period
|
||||||
|
// - Return better status information
|
||||||
|
|
||||||
|
WalletCoordinator.getInstance(applicationContext).synchronizer
|
||||||
|
.flatMapLatest {
|
||||||
|
it?.status?.combine(it.progress) { status, progress ->
|
||||||
|
StatusAndProgress(status, progress)
|
||||||
|
} ?: emptyFlow()
|
||||||
|
}
|
||||||
|
.takeWhile {
|
||||||
|
it.status != Synchronizer.Status.DISCONNECTED && it.progress < ONE_HUNDRED_PERCENT
|
||||||
|
}
|
||||||
|
.collect()
|
||||||
|
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ONE_HUNDRED_PERCENT = 100
|
||||||
|
|
||||||
|
/*
|
||||||
|
* There may be better periods; we have not optimized for this yet.
|
||||||
|
*/
|
||||||
|
private val DEFAULT_SYNC_PERIOD = 24.hours
|
||||||
|
|
||||||
|
fun newWorkRequest(): PeriodicWorkRequest {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiresStorageNotLow(true)
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return PeriodicWorkRequestBuilder<SyncWorker>(DEFAULT_SYNC_PERIOD.toJavaDuration())
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class StatusAndProgress(val status: Synchronizer.Status, val progress: Int)
|
|
@ -0,0 +1,26 @@
|
||||||
|
package cash.z.ecc.work
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
|
||||||
|
object WorkIds {
|
||||||
|
const val WORK_ID_BACKGROUND_SYNC = "co.electriccoin.zcash.background_sync"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* For now, sync is always enabled. In the future, we can consider whether a preference
|
||||||
|
* is a good idea.
|
||||||
|
*
|
||||||
|
* Also note that if we ever change the sync interval period, this code won't re-run on
|
||||||
|
* existing installations unless we make changes to call this during app startup.
|
||||||
|
*/
|
||||||
|
fun enableBackgroundSynchronization(context: Context) {
|
||||||
|
val workManager = WorkManager.getInstance(context)
|
||||||
|
|
||||||
|
workManager.enqueueUniquePeriodicWork(
|
||||||
|
WORK_ID_BACKGROUND_SYNC,
|
||||||
|
ExistingPeriodicWorkPolicy.REPLACE,
|
||||||
|
SyncWorker.newWorkRequest()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue