[#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:
Carter Jernigan 2022-02-04 07:37:08 -05:00 committed by GitHub
parent b6ed705866
commit 52588e6bd3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 311 additions and 96 deletions

View File

@ -43,4 +43,4 @@ There are some app-wide resources that share a common namespace, and these shoul
* Notification Channels
* No notification channels are currently defined
* WorkManager Tags
* No WorkManager tags are currently defined
* "co.electriccoin.zcash.background_sync" is defined in `WorkIds.kt`

View File

@ -85,6 +85,7 @@ ANDROIDX_TEST_JUNIT_VERSION=1.1.3
ANDROIDX_TEST_ORCHESTRATOR_VERSION=1.4.1
ANDROIDX_TEST_VERSION=1.4.1-alpha03
ANDROIDX_UI_AUTOMATOR_VERSION=2.2.0-alpha1
ANDROIDX_WORK_MANAGER_VERSION=2.7.1
CORE_LIBRARY_DESUGARING_VERSION=1.1.5
GOOGLE_MATERIAL_VERSION=1.4.0
JACOCO_VERSION=0.8.7

View File

@ -1,6 +1,6 @@
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.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]
* 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)) {
fun ZcashNetwork.Companion.fromResources(context: Context) =
if (context.resources.getBoolean(R.bool.zcash_is_testnet)) {
ZcashNetwork.Testnet
} else {
ZcashNetwork.Mainnet

View File

@ -99,6 +99,7 @@ dependencyResolutionManagement {
val androidxTestOrchestratorVersion = extra["ANDROIDX_TEST_ORCHESTRATOR_VERSION"].toString()
val androidxTestVersion = extra["ANDROIDX_TEST_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 googleMaterialVersion = extra["GOOGLE_MATERIAL_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-splash").to("androidx.core:core-splashscreen:$androidxSplashScreenVersion")
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("google-material").to("com.google.android.material:material:$googleMaterialVersion")
alias("kotlin-stdlib").to("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")

View File

@ -47,6 +47,7 @@ dependencies {
implementation(libs.androidx.core)
implementation(libs.androidx.lifecycle.livedata)
implementation(libs.androidx.splash)
implementation(libs.androidx.workmanager)
implementation(libs.bundles.androidx.compose)
implementation(libs.google.material)
implementation(libs.kotlin.stdlib)

View File

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

View File

@ -1,7 +1,7 @@
package cash.z.ecc.ui.preference
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.api.PreferenceProvider
@ -9,7 +9,9 @@ object EncryptedPreferenceSingleton {
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)
}

View File

@ -1,7 +1,7 @@
package cash.z.ecc.ui.preference
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.api.PreferenceProvider
@ -9,7 +9,9 @@ object StandardPreferenceSingleton {
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)
}

View File

@ -3,7 +3,6 @@ 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
@ -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.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.global.WalletCoordinator
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 cash.z.ecc.work.WorkIds
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
@ -44,23 +39,22 @@ 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) {
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()
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 {
// 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))
}
val synchronizer = walletCoordinator.synchronizer.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
null
)
/**
* 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))
}
val secretState: StateFlow<SecretState> = persistableWallet
val secretState: StateFlow<SecretState> = walletCoordinator.persistableWallet
.combine(isBackupComplete) { persistableWallet: PersistableWallet?, isBackupComplete: Boolean ->
if (null == persistableWallet) {
SecretState.None
@ -85,33 +79,6 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
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()
@ -170,6 +137,8 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
persistWalletMutex.withLock {
EncryptedPreferenceKeys.PERSISTABLE_WALLET.putValue(preferenceProvider, persistableWallet)
}
WorkIds.enableBackgroundSynchronization(application)
}
}
@ -199,11 +168,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
*/
fun rescanBlockchain() {
viewModelScope.launch {
synchronizerMutex.withLock {
synchronizer.value?.let {
it.rewindToNearestHeight(it.latestBirthdayHeight, true)
}
}
walletCoordinator.rescanBlockchain()
}
}
@ -213,45 +178,8 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
* 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)
}
}
}
walletCoordinator.wipeWallet()
}
}
}

View File

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

View File

@ -8,7 +8,7 @@ import kotlinx.coroutines.sync.withLock
*
* 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 val mutex = Mutex()

View File

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

View File

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