diff --git a/CHANGELOG.md b/CHANGELOG.md index 48469782..b08b604b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,8 @@ Change Log - `Initializer.Config.setViewingKeys` - `cash.z.ecc.android.sdk`: - `Synchronizer.Companion.new` now takes many of the arguments previously passed to `Initializer`. In addition, an optional `seed` argument is required for first-time initialization or if `Synchronizer.new` throws an exception indicating that an internal migration requires the wallet seed. (This second case will be true the first time existing clients upgrade to this new version of the SDK). + - `Synchronizer.new()` now returns an instance that implements the `Closeable` interface. `Synchronizer.stop()` is effectively renamed to `Synchronizer.close()` + - `Synchronizer` ensures that multiple instances cannot be running concurrently with the same network and alias - `Synchronizer.sendToAddress` now takes a `UnifiedSpendingKey` instead of an encoded Sapling extended spending key, and the `fromAccountIndex` argument is now implicit in the `UnifiedSpendingKey`. @@ -51,6 +53,7 @@ Change Log ### Removed - `cash.z.ecc.android.sdk`: - `Initializer` (use `Synchronizer.new` instead) + - `Synchronizer.start()` - Synchronizer is now started automatically when constructing a new instance. - `Synchronizer.getAddress` (use `Synchronizer.getUnifiedAddress` instead). - `Synchronizer.getShieldedAddress` (use `Synchronizer.getSaplingAddress` instead) - `Synchronizer.cancel` diff --git a/MIGRATIONS.md b/MIGRATIONS.md index 4490e127..cd4d86e8 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -1,12 +1,24 @@ Troubleshooting Migrations ========== -Migration to Version 1.10 +Migration to Version 1.11 --------------------------------- The way the SDK is initialized has changed. The `Initializer` object has been removed and `Synchronizer.new` now takes a longer parameter list which includes the parameters previously passed to `Initializer`. SDK initialization also now requires access to the seed bytes at two times: 1. during new wallet creation and 2. during upgrade of an existing wallet to SDK 1.10 due to internal data migrations. To handle case #2, client should wrap `Synchronizer.new()` with a try-catch for `InitializerException.SeedRequired`. Clients can pass `null` to try to initialize the SDK without the seed, then try again if the exception is thrown to indicate the seed is needed. This pattern future-proofs initialization, as the seed may be required by future SDK updates. +`Synchronizer.stop()` has been removed. `Synchronizer.new()` now returns an instance that implements the `Closeable` interface. This effectively means that calls to `stop()` are replaced with `close()`. This change also enables greater safety within client applications, as the Closeable interface can be hidden from global synchronizer instances. For exmaple: +``` +val synchronizerFlow: Flow = callbackFlow { + val closeableSynchronizer: CloseableSynchronizer = Synchronizer.new(...) + + send(closeableSynchronizer) + awaitClose { + closeableSynchronizer.close() + } +} +``` + To improve type safety of the public API, Zcash account indexes are now represented by an `Account` object. The SDK currently only supports the default account, `Account.DEFAULT`. Migration will effectively require replacing APIs with an account `0` with `Account.DEFAULT`. To support Network Upgrade 5, the way keys are generated has changed. diff --git a/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/reorgs/InboundTxTests.kt b/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/reorgs/InboundTxTests.kt index 30cb53a3..d49e1df7 100644 --- a/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/reorgs/InboundTxTests.kt +++ b/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/reorgs/InboundTxTests.kt @@ -97,7 +97,6 @@ class InboundTxTests : ScopedTest() { .stageEmptyBlocks(firstBlock + 1, 100) .applyTipHeight(BlockHeight.new(ZcashNetwork.Mainnet, targetTxBlock.value - 1)) - sithLord.synchronizer.start(classScope) sithLord.await() } } diff --git a/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/reorgs/ReorgSetupTest.kt b/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/reorgs/ReorgSetupTest.kt index 50af386d..c28d02c5 100644 --- a/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/reorgs/ReorgSetupTest.kt +++ b/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/reorgs/ReorgSetupTest.kt @@ -42,7 +42,6 @@ class ReorgSetupTest : ScopedTest() { @JvmStatic fun startOnce() { sithLord.enterTheDarkside() - sithLord.synchronizer.start(classScope) } } } diff --git a/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/reorgs/ReorgSmallTest.kt b/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/reorgs/ReorgSmallTest.kt index 9a3b45f9..ad1ac534 100644 --- a/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/reorgs/ReorgSmallTest.kt +++ b/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/reorgs/ReorgSmallTest.kt @@ -60,7 +60,6 @@ class ReorgSmallTest : ScopedTest() { validator.onReorg { _, _ -> hadReorg = true } - sithLord.synchronizer.start(classScope) } } } diff --git a/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/test/DarksideTest.kt b/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/test/DarksideTest.kt index 2bdec218..9da81699 100644 --- a/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/test/DarksideTest.kt +++ b/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/test/DarksideTest.kt @@ -7,7 +7,6 @@ open class DarksideTest : ScopedTest() { fun runOnce(block: () -> Unit) { if (!ranOnce) { sithLord.enterTheDarkside() - sithLord.synchronizer.start(classScope) block() ranOnce = true } diff --git a/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/test/TestWallet.kt b/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/test/TestWallet.kt index fca3b33e..3977b079 100644 --- a/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/test/TestWallet.kt +++ b/darkside-test-lib/src/androidTest/java/cash/z/ecc/android/sdk/darkside/test/TestWallet.kt @@ -97,12 +97,8 @@ class TestWallet( throw TimeoutException("Failed to sync wallet within ${timeout}ms") } } - if (!synchronizer.isStarted) { - twig("Starting sync") - synchronizer.start(walletScope) - } else { - twig("Awaiting next SYNCED status") - } + + twig("Awaiting next SYNCED status") // block until synced synchronizer.status.first { it == Synchronizer.Status.SYNCED } @@ -154,7 +150,7 @@ class TestWallet( twig("Scheduling a stop in ${timeout}ms") walletScope.launch { delay(timeout) - synchronizer.stop() + synchronizer.close() } } synchronizer.status.first { it == Synchronizer.Status.STOPPED } diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/SharedViewModel.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/SharedViewModel.kt index 411f632c..dfecc003 100644 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/SharedViewModel.kt +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/SharedViewModel.kt @@ -4,15 +4,36 @@ 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.Synchronizer import cash.z.ecc.android.sdk.demoapp.util.fromResources +import cash.z.ecc.android.sdk.ext.BenchmarkingExt +import cash.z.ecc.android.sdk.ext.onFirst +import cash.z.ecc.android.sdk.fixture.BlockRangeFixture import cash.z.ecc.android.sdk.internal.twig import cash.z.ecc.android.sdk.model.BlockHeight +import cash.z.ecc.android.sdk.model.LightWalletEndpoint import cash.z.ecc.android.sdk.model.ZcashNetwork +import cash.z.ecc.android.sdk.model.defaultForNetwork +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.UUID +import kotlin.time.Duration.Companion.seconds /** * Shared mutable state for the demo @@ -36,6 +57,54 @@ class SharedViewModel(application: Application) : AndroidViewModel(application) // publicly, this is read-only val birthdayHeight: StateFlow get() = _blockHeight + private val lockoutMutex = Mutex() + + private val lockoutIdFlow = MutableStateFlow(null) + + @OptIn(ExperimentalCoroutinesApi::class) + private val synchronizerOrLockout: Flow = lockoutIdFlow.flatMapLatest { lockoutId -> + if (null != lockoutId) { + flowOf(InternalSynchronizerStatus.Lockout(lockoutId)) + } else { + callbackFlow { + // Use a BIP-39 library to convert a seed phrase into a byte array. Most wallets already + // have the seed stored + val seedBytes = Mnemonics.MnemonicCode(seedPhrase.value).toSeed() + + val network = ZcashNetwork.fromResources(application) + val synchronizer = Synchronizer.new( + application, + network, + lightWalletEndpoint = LightWalletEndpoint.defaultForNetwork(network), + seed = seedBytes, + birthday = if (BenchmarkingExt.isBenchmarking()) { + BlockRangeFixture.new().start + } else { + birthdayHeight.value + } + ) + + send(InternalSynchronizerStatus.Available(synchronizer)) + awaitClose { + synchronizer.close() + } + } + } + } + + // Note that seed and birthday shouldn't be changed once a synchronizer is first collected + val synchronizerFlow: StateFlow = synchronizerOrLockout.map { + when (it) { + is InternalSynchronizerStatus.Available -> it.synchronizer + is InternalSynchronizerStatus.Lockout -> null + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(DEFAULT_ANDROID_STATE_TIMEOUT.inWholeMilliseconds, 0), + initialValue = + null + ) + fun updateSeedPhrase(newPhrase: String?): Boolean { return if (isValidSeedPhrase(newPhrase)) { _seedPhrase.value = newPhrase!! @@ -47,11 +116,22 @@ class SharedViewModel(application: Application) : AndroidViewModel(application) fun resetSDK() { viewModelScope.launch { - with(getApplication()) { - Synchronizer.erase( - appContext = applicationContext, - network = ZcashNetwork.fromResources(applicationContext) - ) + lockoutMutex.withLock { + val lockoutId = UUID.randomUUID() + lockoutIdFlow.value = lockoutId + + synchronizerOrLockout + .filterIsInstance() + .filter { it.id == lockoutId } + .onFirst { + val didDelete = Synchronizer.erase( + appContext = getApplication(), + network = ZcashNetwork.fromResources(getApplication()) + ) + twig("SDK erase result: $didDelete") + } + + lockoutIdFlow.value = null } } } @@ -74,4 +154,13 @@ class SharedViewModel(application: Application) : AndroidViewModel(application) false } } + + private sealed class InternalSynchronizerStatus { + class Available(val synchronizer: Synchronizer) : InternalSynchronizerStatus() + class Lockout(val id: UUID) : InternalSynchronizerStatus() + } + + companion object { + private val DEFAULT_ANDROID_STATE_TIMEOUT = 5.seconds + } } diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getaddress/GetAddressFragment.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getaddress/GetAddressFragment.kt index 880bc538..462f9879 100644 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getaddress/GetAddressFragment.kt +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getaddress/GetAddressFragment.kt @@ -2,22 +2,20 @@ package cash.z.ecc.android.sdk.demoapp.demos.getaddress import android.os.Bundle import android.view.LayoutInflater +import android.view.View +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import cash.z.ecc.android.bip39.Mnemonics -import cash.z.ecc.android.bip39.toSeed -import cash.z.ecc.android.sdk.Synchronizer +import androidx.lifecycle.repeatOnLifecycle import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetAddressBinding import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext import cash.z.ecc.android.sdk.demoapp.util.ProvideAddressBenchmarkTrace import cash.z.ecc.android.sdk.demoapp.util.fromResources -import cash.z.ecc.android.sdk.model.LightWalletEndpoint import cash.z.ecc.android.sdk.model.ZcashNetwork -import cash.z.ecc.android.sdk.model.defaultForNetwork import cash.z.ecc.android.sdk.tool.DerivationTool import cash.z.ecc.android.sdk.type.UnifiedFullViewingKey +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking /** * Displays the address associated with the seed defined by the default config. To modify the seed @@ -25,62 +23,36 @@ import kotlinx.coroutines.runBlocking */ class GetAddressFragment : BaseDemoFragment() { - private lateinit var synchronizer: Synchronizer private lateinit var viewingKey: UnifiedFullViewingKey - private lateinit var seed: ByteArray - - /** - * Initialize the required values that would normally live outside the demo but are repeated - * here for completeness so that each demo file can serve as a standalone example. - */ - private fun setup() { - // defaults to the value of `DemoConfig.seedWords` but can also be set by the user - val seedPhrase = sharedViewModel.seedPhrase.value - - // Use a BIP-39 library to convert a seed phrase into a byte array. Most wallets already - // have the seed stored - seed = Mnemonics.MnemonicCode(seedPhrase).toSeed() - - // converting seed into viewingKey - viewingKey = runBlocking { - DerivationTool.deriveUnifiedFullViewingKeys( - seed, - ZcashNetwork.fromResources(requireApplicationContext()) - ).first() - } - - val network = ZcashNetwork.fromResources(requireApplicationContext()) - synchronizer = Synchronizer.newBlocking( - requireApplicationContext(), - network, - lightWalletEndpoint = LightWalletEndpoint.defaultForNetwork(network), - seed = seed, - birthday = network.saplingActivationHeight - ) - } private fun displayAddress() { - viewLifecycleOwner.lifecycleScope.launchWhenStarted { - binding.unifiedAddress.apply { - reportTraceEvent(ProvideAddressBenchmarkTrace.Event.UNIFIED_ADDRESS_START) - val uaddress = synchronizer.getUnifiedAddress() - reportTraceEvent(ProvideAddressBenchmarkTrace.Event.UNIFIED_ADDRESS_END) - text = uaddress - setOnClickListener { copyToClipboard(uaddress) } - } - binding.saplingAddress.apply { - reportTraceEvent(ProvideAddressBenchmarkTrace.Event.SAPLING_ADDRESS_START) - val sapling = synchronizer.getSaplingAddress() - reportTraceEvent(ProvideAddressBenchmarkTrace.Event.SAPLING_ADDRESS_END) - text = sapling - setOnClickListener { copyToClipboard(sapling) } - } - binding.transparentAddress.apply { - reportTraceEvent(ProvideAddressBenchmarkTrace.Event.TRANSPARENT_ADDRESS_START) - val transparent = synchronizer.getTransparentAddress() - reportTraceEvent(ProvideAddressBenchmarkTrace.Event.TRANSPARENT_ADDRESS_END) - text = transparent - setOnClickListener { copyToClipboard(transparent) } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + sharedViewModel.synchronizerFlow.filterNotNull().collect { synchronizer -> + binding.unifiedAddress.apply { + reportTraceEvent(ProvideAddressBenchmarkTrace.Event.UNIFIED_ADDRESS_START) + val uaddress = synchronizer.getUnifiedAddress() + reportTraceEvent(ProvideAddressBenchmarkTrace.Event.UNIFIED_ADDRESS_END) + text = uaddress + setOnClickListener { copyToClipboard(uaddress) } + } + binding.saplingAddress.apply { + reportTraceEvent(ProvideAddressBenchmarkTrace.Event.SAPLING_ADDRESS_START) + val sapling = synchronizer.getSaplingAddress() + reportTraceEvent(ProvideAddressBenchmarkTrace.Event.SAPLING_ADDRESS_END) + text = sapling + setOnClickListener { copyToClipboard(sapling) } + } + binding.transparentAddress.apply { + reportTraceEvent(ProvideAddressBenchmarkTrace.Event.TRANSPARENT_ADDRESS_START) + val transparent = synchronizer.getTransparentAddress() + reportTraceEvent(ProvideAddressBenchmarkTrace.Event.TRANSPARENT_ADDRESS_END) + text = transparent + setOnClickListener { copyToClipboard(transparent) } + } + } + } } } } @@ -92,11 +64,11 @@ class GetAddressFragment : BaseDemoFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) reportTraceEvent(ProvideAddressBenchmarkTrace.Event.ADDRESS_SCREEN_START) - setup() } - override fun onResume() { - super.onResume() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + displayAddress() } diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getbalance/GetBalanceFragment.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getbalance/GetBalanceFragment.kt index e085f0c5..ed48ac5e 100644 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getbalance/GetBalanceFragment.kt +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/getbalance/GetBalanceFragment.kt @@ -4,7 +4,9 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.Menu import android.view.View +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import cash.z.ecc.android.bip39.Mnemonics import cash.z.ecc.android.bip39.toSeed import cash.z.ecc.android.sdk.Synchronizer @@ -15,19 +17,17 @@ import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetBalanceBinding import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext import cash.z.ecc.android.sdk.demoapp.util.SyncBlockchainBenchmarkTrace import cash.z.ecc.android.sdk.demoapp.util.fromResources -import cash.z.ecc.android.sdk.ext.BenchmarkingExt import cash.z.ecc.android.sdk.ext.ZcashSdk -import cash.z.ecc.android.sdk.ext.collectWith import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString -import cash.z.ecc.android.sdk.fixture.BlockRangeFixture import cash.z.ecc.android.sdk.internal.twig import cash.z.ecc.android.sdk.model.Account -import cash.z.ecc.android.sdk.model.LightWalletEndpoint 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.model.defaultForNetwork import cash.z.ecc.android.sdk.tool.DerivationTool +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch /** @@ -37,8 +37,6 @@ import kotlinx.coroutines.launch @Suppress("TooManyFunctions") class GetBalanceFragment : BaseDemoFragment() { - private lateinit var synchronizer: Synchronizer - override fun inflateBinding(layoutInflater: LayoutInflater): FragmentGetBalanceBinding = FragmentGetBalanceBinding.inflate(layoutInflater) @@ -46,7 +44,6 @@ class GetBalanceFragment : BaseDemoFragment() { super.onCreate(savedInstanceState) reportTraceEvent(SyncBlockchainBenchmarkTrace.Event.BALANCE_SCREEN_START) setHasOptionsMenu(true) - setup() } override fun onPrepareOptionsMenu(menu: Menu) { @@ -60,28 +57,6 @@ class GetBalanceFragment : BaseDemoFragment() { reportTraceEvent(SyncBlockchainBenchmarkTrace.Event.BALANCE_SCREEN_END) } - private fun setup() { - // defaults to the value of `DemoConfig.seedWords` but can also be set by the user - val seedPhrase = sharedViewModel.seedPhrase.value - - // Use a BIP-39 library to convert a seed phrase into a byte array. Most wallets already - // have the seed stored - val seed = Mnemonics.MnemonicCode(seedPhrase).toSeed() - - val network = ZcashNetwork.fromResources(requireApplicationContext()) - synchronizer = Synchronizer.newBlocking( - requireApplicationContext(), - network, - lightWalletEndpoint = LightWalletEndpoint.defaultForNetwork(network), - seed = seed, - birthday = if (BenchmarkingExt.isBenchmarking()) { - BlockRangeFixture.new().start - } else { - sharedViewModel.birthdayHeight.value - } - ) - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -92,26 +67,62 @@ class GetBalanceFragment : BaseDemoFragment() { binding.shield.apply { setOnClickListener { lifecycleScope.launch { - synchronizer.shieldFunds(DerivationTool.deriveUnifiedSpendingKey(seed, network, Account.DEFAULT)) + sharedViewModel.synchronizerFlow.value?.shieldFunds( + DerivationTool.deriveUnifiedSpendingKey( + seed, + network, + Account.DEFAULT + ) + ) } } } - } - override fun onResume() { - super.onResume() - // the lifecycleScope is used to dispose of the synchronize when the fragment dies - synchronizer.start(lifecycleScope) monitorChanges() } + @OptIn(ExperimentalCoroutinesApi::class) private fun monitorChanges() { - synchronizer.status.collectWith(lifecycleScope, ::onStatus) - synchronizer.progress.collectWith(lifecycleScope, ::onProgress) - synchronizer.processorInfo.collectWith(lifecycleScope, ::onProcessorInfoUpdated) - synchronizer.orchardBalances.collectWith(lifecycleScope, ::onOrchardBalance) - synchronizer.saplingBalances.collectWith(lifecycleScope, ::onSaplingBalance) - synchronizer.transparentBalances.collectWith(lifecycleScope, ::onTransparentBalance) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + sharedViewModel.synchronizerFlow + .filterNotNull() + .flatMapLatest { it.status } + .collect { onStatus(it) } + } + launch { + sharedViewModel.synchronizerFlow + .filterNotNull() + .flatMapLatest { it.progress } + .collect { onProgress(it) } + } + launch { + sharedViewModel.synchronizerFlow + .filterNotNull() + .flatMapLatest { it.processorInfo } + .collect { onProcessorInfoUpdated(it) } + } + launch { + sharedViewModel.synchronizerFlow + .filterNotNull() + .flatMapLatest { it.saplingBalances } + .collect { onSaplingBalance(it) } + } + launch { + sharedViewModel.synchronizerFlow + .filterNotNull() + .flatMapLatest { it.orchardBalances } + .collect { onOrchardBalance(it) } + } + launch { + sharedViewModel.synchronizerFlow + .filterNotNull() + .flatMapLatest { it.transparentBalances } + .collect { onTransparentBalance(it) } + } + } + } } private fun onOrchardBalance( @@ -181,9 +192,11 @@ class GetBalanceFragment : BaseDemoFragment() { traceEvents?.forEach { reportTraceEvent(it) } binding.textStatus.text = "Status: $status" - onOrchardBalance(synchronizer.orchardBalances.value) - onSaplingBalance(synchronizer.saplingBalances.value) - onTransparentBalance(synchronizer.transparentBalances.value) + sharedViewModel.synchronizerFlow.value?.let { synchronizer -> + onOrchardBalance(synchronizer.orchardBalances.value) + onSaplingBalance(synchronizer.saplingBalances.value) + onTransparentBalance(synchronizer.transparentBalances.value) + } } @Suppress("MagicNumber") diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/listtransactions/ListTransactionsFragment.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/listtransactions/ListTransactionsFragment.kt index 824553ec..26561cee 100644 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/listtransactions/ListTransactionsFragment.kt +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/listtransactions/ListTransactionsFragment.kt @@ -4,27 +4,21 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.Menu import android.view.View -import android.view.ViewGroup +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager -import cash.z.ecc.android.bip39.Mnemonics -import cash.z.ecc.android.bip39.toSeed import cash.z.ecc.android.sdk.Synchronizer import cash.z.ecc.android.sdk.block.CompactBlockProcessor import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment import cash.z.ecc.android.sdk.demoapp.R import cash.z.ecc.android.sdk.demoapp.databinding.FragmentListTransactionsBinding -import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext -import cash.z.ecc.android.sdk.demoapp.util.fromResources -import cash.z.ecc.android.sdk.ext.collectWith import cash.z.ecc.android.sdk.internal.twig -import cash.z.ecc.android.sdk.model.Account -import cash.z.ecc.android.sdk.model.LightWalletEndpoint import cash.z.ecc.android.sdk.model.TransactionOverview -import cash.z.ecc.android.sdk.model.ZcashNetwork -import cash.z.ecc.android.sdk.model.defaultForNetwork -import cash.z.ecc.android.sdk.tool.DerivationTool -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch /** * List all transactions related to the given seed, since the given birthday. This begins by @@ -35,40 +29,11 @@ import kotlinx.coroutines.runBlocking */ @Suppress("TooManyFunctions") class ListTransactionsFragment : BaseDemoFragment() { - private lateinit var synchronizer: Synchronizer private lateinit var adapter: TransactionAdapter private lateinit var address: String private var status: Synchronizer.Status? = null private val isSynced get() = status == Synchronizer.Status.SYNCED - /** - * Initialize the required values that would normally live outside the demo but are repeated - * here for completeness so that each demo file can serve as a standalone example. - */ - private fun setup() { - // defaults to the value of `DemoConfig.seedWords` but can also be set by the user - var seedPhrase = sharedViewModel.seedPhrase.value - - // Use a BIP-39 library to convert a seed phrase into a byte array. Most wallets already - // have the seed stored - val seed = Mnemonics.MnemonicCode(seedPhrase).toSeed() - val network = ZcashNetwork.fromResources(requireApplicationContext()) - address = runBlocking { - DerivationTool.deriveUnifiedAddress( - seed, - ZcashNetwork.fromResources(requireApplicationContext()), - Account.DEFAULT - ) - } - synchronizer = Synchronizer.newBlocking( - requireApplicationContext(), - network, - lightWalletEndpoint = LightWalletEndpoint.defaultForNetwork(network), - seed = seed, - birthday = sharedViewModel.birthdayHeight.value - ) - } - private fun initTransactionUI() { binding.recyclerTransactions.layoutManager = LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false) @@ -76,14 +41,35 @@ class ListTransactionsFragment : BaseDemoFragment() { - private lateinit var seed: ByteArray - private lateinit var synchronizer: Synchronizer private lateinit var adapter: UtxoAdapter private val address: String = "t1RwbKka1CnktvAJ1cSqdn7c6PXWG4tZqgd" private var status: Synchronizer.Status? = null @@ -56,30 +53,6 @@ class ListUtxosFragment : BaseDemoFragment() { override fun inflateBinding(layoutInflater: LayoutInflater): FragmentListUtxosBinding = FragmentListUtxosBinding.inflate(layoutInflater) - /** - * Initialize the required values that would normally live outside the demo but are repeated - * here for completeness so that each demo file can serve as a standalone example. - */ - private fun setup() { - // Use a BIP-39 library to convert a seed phrase into a byte array. Most wallets already - // have the seed stored - seed = Mnemonics.MnemonicCode(sharedViewModel.seedPhrase.value).toSeed() - val network = ZcashNetwork.fromResources(requireApplicationContext()) - synchronizer = Synchronizer.newBlocking( - requireApplicationContext(), - network, - alias = "Demo_Utxos", - lightWalletEndpoint = LightWalletEndpoint.defaultForNetwork(network), - seed = seed, - birthday = sharedViewModel.birthdayHeight.value - ) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setup() - } - private fun initUi() { binding.inputAddress.setText(address) binding.inputRangeStart.setText( @@ -96,57 +69,59 @@ class ListUtxosFragment : BaseDemoFragment() { } private fun downloadTransactions() { - binding.textStatus.text = "loading..." - binding.textStatus.post { - val network = ZcashNetwork.fromResources(requireApplicationContext()) - binding.textStatus.requestFocus() - val addressToUse = binding.inputAddress.text.toString() - val startToUse = max( - binding.inputRangeStart.text.toString().toLongOrNull() - ?: network.saplingActivationHeight.value, - network.saplingActivationHeight.value - ) - val endToUse = binding.inputRangeEnd.text.toString().toLongOrNull() - ?: getUxtoEndHeight(requireApplicationContext()).value - var allStart = now - twig("loading transactions in range $startToUse..$endToUse") - val txids = lightWalletService?.getTAddressTransactions( - addressToUse, - BlockHeight.new(network, startToUse)..BlockHeight.new(network, endToUse) - ) - var delta = now - allStart - updateStatus("found ${txids?.size} transactions in ${delta}ms.", false) + sharedViewModel.synchronizerFlow.value?.let { synchronizer -> + binding.textStatus.text = "loading..." + binding.textStatus.post { + val network = ZcashNetwork.fromResources(requireApplicationContext()) + binding.textStatus.requestFocus() + val addressToUse = binding.inputAddress.text.toString() + val startToUse = max( + binding.inputRangeStart.text.toString().toLongOrNull() + ?: network.saplingActivationHeight.value, + network.saplingActivationHeight.value + ) + val endToUse = binding.inputRangeEnd.text.toString().toLongOrNull() + ?: getUxtoEndHeight(requireApplicationContext()).value + var allStart = now + twig("loading transactions in range $startToUse..$endToUse") + val txids = lightWalletService?.getTAddressTransactions( + addressToUse, + BlockHeight.new(network, startToUse)..BlockHeight.new(network, endToUse) + ) + var delta = now - allStart + updateStatus("found ${txids?.size} transactions in ${delta}ms.", false) - txids?.map { - // Disabled during migration to newer SDK version; this appears to have been - // leveraging non-public APIs in the SDK so perhaps should be removed - // it.data.apply { - // try { - // runBlocking { initializer.rustBackend.decryptAndStoreTransaction(toByteArray()) } - // } catch (t: Throwable) { - // twig("failed to decrypt and store transaction due to: $t") - // } - // } - }?.let { _ -> - // Disabled during migration to newer SDK version; this appears to have been - // leveraging non-public APIs in the SDK so perhaps should be removed - // val parseStart = now - // val tList = LocalRpcTypes.TransactionDataList.newBuilder().addAllData(txData).build() - // val parsedTransactions = initializer.rustBackend.parseTransactionDataList(tList) - // delta = now - parseStart - // updateStatus("parsed txs in ${delta}ms.") - } - (synchronizer as SdkSynchronizer).refreshTransactions() - delta = now - allStart - updateStatus("Total time ${delta}ms.") + txids?.map { + // Disabled during migration to newer SDK version; this appears to have been + // leveraging non-public APIs in the SDK so perhaps should be removed + // it.data.apply { + // try { + // runBlocking { initializer.rustBackend.decryptAndStoreTransaction(toByteArray()) } + // } catch (t: Throwable) { + // twig("failed to decrypt and store transaction due to: $t") + // } + // } + }?.let { _ -> + // Disabled during migration to newer SDK version; this appears to have been + // leveraging non-public APIs in the SDK so perhaps should be removed + // val parseStart = now + // val tList = LocalRpcTypes.TransactionDataList.newBuilder().addAllData(txData).build() + // val parsedTransactions = initializer.rustBackend.parseTransactionDataList(tList) + // delta = now - parseStart + // updateStatus("parsed txs in ${delta}ms.") + } + (synchronizer as SdkSynchronizer).refreshTransactions() + delta = now - allStart + updateStatus("Total time ${delta}ms.") - lifecycleScope.launch { - withContext(Dispatchers.IO) { - finalCount = (synchronizer as SdkSynchronizer).getTransactionCount().toInt() - withContext(Dispatchers.Main) { - @Suppress("MagicNumber") - delay(100) - updateStatus("Also found ${finalCount - initialCount} shielded txs") + lifecycleScope.launch { + withContext(Dispatchers.IO) { + finalCount = synchronizer.getTransactionCount() + withContext(Dispatchers.Main) { + @Suppress("MagicNumber") + delay(100) + updateStatus("Also found ${finalCount - initialCount} shielded txs") + } } } } @@ -167,72 +142,58 @@ class ListUtxosFragment : BaseDemoFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initUi() - } - - override fun onResume() { - super.onResume() - resetInBackground() - viewLifecycleOwner.lifecycleScope.launchWhenStarted { - binding.inputAddress.setText( - synchronizer.getTransparentAddress(Account.DEFAULT) - ) - } + monitorStatus() } var initialCount: Int = 0 var finalCount: Int = 0 - @Suppress("TooGenericExceptionCaught") - private fun resetInBackground() { - try { - lifecycleScope.launch { - withContext(Dispatchers.IO) { - synchronizer.prepare() - initialCount = (synchronizer as SdkSynchronizer).getTransactionCount().toInt() - } - - onTransactionsUpdated(synchronizer.clearedTransactions.first()) - } - - // synchronizer.receivedTransactions.collectWith(lifecycleScope, ::onTransactionsUpdated) - } catch (t: Throwable) { - twig("failed to start the synchronizer!!! due to : $t") - } - } - - fun onResetComplete() { - initTransactionUi() - startSynchronizer() - monitorStatus() - } - - fun onClear() { - synchronizer.stop() - } - private fun initTransactionUi() { binding.recyclerTransactions.layoutManager = LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false) adapter = UtxoAdapter() binding.recyclerTransactions.adapter = adapter - // lifecycleScope.launch { - // address = synchronizer.getAddress() - // synchronizer.receivedTransactions.onEach { - // onTransactionsUpdated(it) - // }.launchIn(this) - // } - } - - private fun startSynchronizer() { - lifecycleScope.apply { - synchronizer.start(this) - } } + @OptIn(ExperimentalCoroutinesApi::class) private fun monitorStatus() { - synchronizer.status.collectWith(lifecycleScope, ::onStatus) - synchronizer.processorInfo.collectWith(lifecycleScope, ::onProcessorInfoUpdated) - synchronizer.progress.collectWith(lifecycleScope, ::onProgress) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + sharedViewModel.synchronizerFlow + .filterNotNull() + .flatMapLatest { it.status } + .collect { onStatus(it) } + } + launch { + sharedViewModel.synchronizerFlow + .filterNotNull() + .flatMapLatest { it.progress } + .collect { onProgress(it) } + } + launch { + sharedViewModel.synchronizerFlow + .filterNotNull() + .flatMapLatest { it.processorInfo } + .collect { onProcessorInfoUpdated(it) } + } + launch { + sharedViewModel.synchronizerFlow + .filterNotNull() + .flatMapLatest { it.clearedTransactions } + .collect { onTransactionsUpdated(it) } + } + launch { + sharedViewModel.synchronizerFlow + .filterNotNull() + .collect { + binding.inputAddress.setText( + it.getTransparentAddress(Account.DEFAULT) + ) + } + } + } + } } private fun onProcessorInfoUpdated(info: CompactBlockProcessor.ProcessorInfo) { @@ -262,10 +223,12 @@ class ListUtxosFragment : BaseDemoFragment() { override fun onActionButtonClicked() { lifecycleScope.launch { withContext(Dispatchers.IO) { - twig("current count: ${(synchronizer as SdkSynchronizer).getTransactionCount()}") - twig("refreshing transactions") - (synchronizer as SdkSynchronizer).refreshTransactions() - twig("current count: ${(synchronizer as SdkSynchronizer).getTransactionCount()}") + sharedViewModel.synchronizerFlow.value?.let { synchronizer -> + twig("current count: ${(synchronizer as SdkSynchronizer).getTransactionCount()}") + twig("refreshing transactions") + synchronizer.refreshTransactions() + twig("current count: ${synchronizer.getTransactionCount()}") + } } } } diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/send/SendFragment.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/send/SendFragment.kt index 14596e5a..78e3e361 100644 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/send/SendFragment.kt +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/demos/send/SendFragment.kt @@ -6,17 +6,15 @@ import android.view.Menu import android.view.View import android.view.ViewGroup import android.widget.TextView +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import cash.z.ecc.android.bip39.Mnemonics -import cash.z.ecc.android.bip39.toSeed +import androidx.lifecycle.repeatOnLifecycle import cash.z.ecc.android.sdk.Synchronizer import cash.z.ecc.android.sdk.block.CompactBlockProcessor import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment import cash.z.ecc.android.sdk.demoapp.DemoConstants import cash.z.ecc.android.sdk.demoapp.R import cash.z.ecc.android.sdk.demoapp.databinding.FragmentSendBinding -import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext -import cash.z.ecc.android.sdk.demoapp.util.fromResources import cash.z.ecc.android.sdk.demoapp.util.mainActivity import cash.z.ecc.android.sdk.ext.collectWith import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString @@ -24,22 +22,19 @@ import cash.z.ecc.android.sdk.ext.convertZecToZatoshi import cash.z.ecc.android.sdk.ext.toZecString import cash.z.ecc.android.sdk.internal.Twig import cash.z.ecc.android.sdk.internal.twig -import cash.z.ecc.android.sdk.model.Account -import cash.z.ecc.android.sdk.model.LightWalletEndpoint import cash.z.ecc.android.sdk.model.PendingTransaction import cash.z.ecc.android.sdk.model.UnifiedSpendingKey import cash.z.ecc.android.sdk.model.WalletBalance -import cash.z.ecc.android.sdk.model.ZcashNetwork -import cash.z.ecc.android.sdk.model.defaultForNetwork import cash.z.ecc.android.sdk.model.isCreated import cash.z.ecc.android.sdk.model.isCreating import cash.z.ecc.android.sdk.model.isFailedEncoding import cash.z.ecc.android.sdk.model.isFailedSubmit import cash.z.ecc.android.sdk.model.isMined import cash.z.ecc.android.sdk.model.isSubmitSuccess -import cash.z.ecc.android.sdk.tool.DerivationTool +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking /** * Demonstrates sending funds to an address. This is the most complex example that puts all of the @@ -51,7 +46,6 @@ import kotlinx.coroutines.runBlocking */ @Suppress("TooManyFunctions") class SendFragment : BaseDemoFragment() { - private lateinit var synchronizer: Synchronizer private lateinit var amountInput: TextView private lateinit var addressInput: TextView @@ -60,35 +54,6 @@ class SendFragment : BaseDemoFragment() { // but since this is a demo, we'll derive it on the fly private lateinit var spendingKey: UnifiedSpendingKey - /** - * Initialize the required values that would normally live outside the demo but are repeated - * here for completeness so that each demo file can serve as a standalone example. - */ - private fun setup() { - // defaults to the value of `DemoConfig.seedWords` but can also be set by the user - var seedPhrase = sharedViewModel.seedPhrase.value - - // Use a BIP-39 library to convert a seed phrase into a byte array. Most wallets already - // have the seed stored - val seed = Mnemonics.MnemonicCode(seedPhrase).toSeed() - - val network = ZcashNetwork.fromResources(requireApplicationContext()) - synchronizer = Synchronizer.newBlocking( - requireApplicationContext(), - network, - lightWalletEndpoint = LightWalletEndpoint.defaultForNetwork(network), - seed = seed, - birthday = sharedViewModel.birthdayHeight.value - ) - spendingKey = runBlocking { - DerivationTool.deriveUnifiedSpendingKey( - seed, - ZcashNetwork.fromResources(requireApplicationContext()), - Account.DEFAULT - ) - } - } - // // Observable properties (done without livedata or flows for simplicity) // @@ -124,11 +89,36 @@ class SendFragment : BaseDemoFragment() { binding.buttonSend.setOnClickListener(::onSend) } + @OptIn(ExperimentalCoroutinesApi::class) private fun monitorChanges() { - synchronizer.status.collectWith(lifecycleScope, ::onStatus) - synchronizer.progress.collectWith(lifecycleScope, ::onProgress) - synchronizer.processorInfo.collectWith(lifecycleScope, ::onProcessorInfoUpdated) - synchronizer.saplingBalances.collectWith(lifecycleScope, ::onBalance) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + sharedViewModel.synchronizerFlow + .filterNotNull() + .flatMapLatest { it.status } + .collect { onStatus(it) } + } + launch { + sharedViewModel.synchronizerFlow + .filterNotNull() + .flatMapLatest { it.progress } + .collect { onProgress(it) } + } + launch { + sharedViewModel.synchronizerFlow + .filterNotNull() + .flatMapLatest { it.processorInfo } + .collect { onProcessorInfoUpdated(it) } + } + launch { + sharedViewModel.synchronizerFlow + .filterNotNull() + .flatMapLatest { it.saplingBalances } + .collect { onBalance(it) } + } + } + } } // @@ -176,12 +166,12 @@ class SendFragment : BaseDemoFragment() { val amount = amountInput.text.toString().toDouble().convertZecToZatoshi() val toAddress = addressInput.text.toString().trim() lifecycleScope.launch { - synchronizer.sendToAddress( + sharedViewModel.synchronizerFlow.value?.sendToAddress( spendingKey, amount, toAddress, "Funds from Demo App" - ).collectWith(lifecycleScope, ::onPendingTxUpdated) + )?.collectWith(lifecycleScope, ::onPendingTxUpdated) } mainActivity()?.hideKeyboard() @@ -246,13 +236,13 @@ class SendFragment : BaseDemoFragment() { savedInstanceState: Bundle? ): View? { val view = super.onCreateView(inflater, container, savedInstanceState) - setup() return view } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initSendUi() + monitorChanges() } override fun onPrepareOptionsMenu(menu: Menu) { @@ -261,13 +251,6 @@ class SendFragment : BaseDemoFragment() { menu.setGroupVisible(R.id.main_menu_group, false) } - override fun onResume() { - super.onResume() - // the lifecycleScope is used to dispose of the synchronizer when the fragment dies - synchronizer.start(lifecycleScope) - monitorChanges() - } - // // BaseDemoFragment overrides // diff --git a/gradle.properties b/gradle.properties index 65bb8c86..2d9009a0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,7 +22,7 @@ ZCASH_ASCII_GPG_KEY= # Configures whether release is an unstable snapshot, therefore published to the snapshot repository. IS_SNAPSHOT=true -LIBRARY_VERSION=1.10.0-beta01 +LIBRARY_VERSION=1.11.0-beta01 # Kotlin compiler warnings can be considered errors, failing the build. ZCASH_IS_TREAT_WARNINGS_AS_ERRORS=true diff --git a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/PullRequestSuite.kt b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/PullRequestSuite.kt deleted file mode 100644 index 3a5cd229..00000000 --- a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/PullRequestSuite.kt +++ /dev/null @@ -1,33 +0,0 @@ -package cash.z.ecc.android.sdk - -import cash.z.ecc.android.sdk.integration.SanityTest -import cash.z.ecc.android.sdk.integration.SmokeTest -import cash.z.ecc.android.sdk.integration.service.ChangeServiceTest -import cash.z.ecc.android.sdk.internal.transaction.PersistentTransactionManagerTest -import cash.z.ecc.android.sdk.jni.BranchIdTest -import cash.z.ecc.android.sdk.jni.TransparentTest -import org.junit.runner.RunWith -import org.junit.runners.Suite - -/** - * Suite of tests to run before submitting a pull request. - * - * For now, these are just the tests that are known to be recently updated and that pass. In the - * near future this suite will contain only fast running tests that can be used to quickly validate - * that a PR hasn't broken anything major. - */ -@RunWith(Suite::class) -@Suite.SuiteClasses( - // Fast tests that only run locally and don't require darksidewalletd or lightwalletd - BranchIdTest::class, - TransparentTest::class, - PersistentTransactionManagerTest::class, - - // potentially exclude because these are long-running (and hit external srvcs) - SanityTest::class, - - // potentially exclude because these hit external services - ChangeServiceTest::class, - SmokeTest::class -) -class PullRequestSuite diff --git a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/integration/SanityTest.kt b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/integration/SanityTest.kt deleted file mode 100644 index 8161a857..00000000 --- a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/integration/SanityTest.kt +++ /dev/null @@ -1,138 +0,0 @@ -package cash.z.ecc.android.sdk.integration - -import androidx.test.core.app.ApplicationProvider -import cash.z.ecc.android.sdk.DefaultSynchronizerFactory -import cash.z.ecc.android.sdk.annotation.MaintainedTest -import cash.z.ecc.android.sdk.annotation.TestPurpose -import cash.z.ecc.android.sdk.ext.BlockExplorer -import cash.z.ecc.android.sdk.internal.SaplingParamTool -import cash.z.ecc.android.sdk.internal.db.DatabaseCoordinator -import cash.z.ecc.android.sdk.internal.twig -import cash.z.ecc.android.sdk.model.BlockHeight -import cash.z.ecc.android.sdk.model.ZcashNetwork -import cash.z.ecc.android.sdk.util.TestWallet -import kotlinx.coroutines.runBlocking -import org.junit.Ignore -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Parameterized -import kotlin.test.DefaultAsserter.assertTrue - -// TODO [#650]: https://github.com/zcash/zcash-android-wallet-sdk/issues/650 - -/** - * This test is intended to run to make sure that basic things are functional and pinpoint what is - * not working. It was originally developed after a major refactor to find what broke. - */ -@MaintainedTest(TestPurpose.COMMIT) -@RunWith(Parameterized::class) -class SanityTest( - private val wallet: TestWallet, - private val encoding: String, - private val birthday: BlockHeight - -) { - - val networkName = wallet.networkName - val name = "$networkName wallet" - - @Test - fun testFilePaths() { - val rustBackend = runBlocking { - DefaultSynchronizerFactory.defaultRustBackend( - ApplicationProvider.getApplicationContext(), - wallet.network, - "TestWallet", - birthday, - SaplingParamTool.new(ApplicationProvider.getApplicationContext()) - ) - } - - assertTrue( - "$name has invalid DataDB file actual=${rustBackend.dataDbFile.absolutePath}" + - "expected suffix=no_backup/co.electricoin.zcash/TestWallet_${networkName}_${DatabaseCoordinator.DB_DATA_NAME}", - rustBackend.dataDbFile.absolutePath.endsWith( - "no_backup/co.electricoin.zcash/TestWallet_${networkName}_${DatabaseCoordinator.DB_DATA_NAME}" - ) - ) - assertTrue( - "$name has invalid CacheDB file $rustBackend.cacheDbFile.absolutePath", - rustBackend.cacheDbFile.absolutePath.endsWith( - "no_backup/co.electricoin.zcash/TestWallet_${networkName}_${DatabaseCoordinator.DB_CACHE_NAME}" - ) - ) - assertTrue( - "$name has invalid params dir ${rustBackend.saplingParamDir.absolutePath}", - rustBackend.saplingParamDir.absolutePath.endsWith( - "no_backup/co.electricoin.zcash" - ) - ) - } - - @Test - @Ignore( - "This test needs to be refactored to a separate test module. It causes SSLHandshakeException: Chain " + - "validation failed on CI" - ) - fun testLatestHeight() = runBlocking { - if (wallet.networkName == "mainnet") { - val expectedHeight = BlockExplorer.fetchLatestHeight() - - // Fetch height directly because the synchronizer hasn't started, yet. Then we test the - // result, only if there is no server communication problem. - val downloaderHeight = runCatching { - return@runCatching wallet.service.getLatestBlockHeight() - }.onFailure { - twig(it) - }.getOrElse { return@runBlocking } - - assertTrue( - "${wallet.endpoint} ${wallet.networkName} Lightwalletd is too far behind. Downloader height $downloaderHeight is more than 10 blocks behind block explorer height $expectedHeight", - expectedHeight - 10 < downloaderHeight.value - ) - } - } - - @Test - @Ignore( - "This test needs to be refactored to a separate test module. It causes SSLHandshakeException: Chain " + - "validation failed on CI" - ) - fun testSingleBlockDownload() = runBlocking { - // Fetch height directly because the synchronizer hasn't started, yet. Then we test the - // result, only if there is no server communication problem. - val height = BlockHeight.new(wallet.network, 1_000_000) - val block = runCatching { - return@runCatching wallet.service.getBlockRange(height..height).first() - }.onFailure { - twig(it) - }.getOrElse { return@runBlocking } - - runCatching { - wallet.service.getLatestBlockHeight() - }.getOrNull() ?: return@runBlocking - assertTrue( - "$networkName failed to return a proper block. Height was ${block.height} but we expected $height", - block.height == height.value - ) - } - - companion object { - @JvmStatic - @Parameterized.Parameters - fun wallets() = listOf( - // Testnet wallet - arrayOf( - TestWallet(TestWallet.Backups.SAMPLE_WALLET), - "uviewtest1m3cyp6tdy3rewtpqazdxlsqkmu7xjedtqmp4da8mvxm87h4as38v5kz4ulw7x7nmgv5d8uwk743a5zt7aurtz2z2g74fu740ecp5fhdgakm6hgzr5jzcl75cmddlufmjpykrpkzj84yz8j5qe9c5935qt2tvd9dpx3m0zw5dwn3t2dtsdyqvy5jstf88w799qre549yyxw7dvk3murm3568ah6wqg5tdjka2ujtgct4q62hw7mfcxcyaeu8l6882hxkt9x4025mx3w35whcrmpxy8fqsh62esatczj8awxtrgnj8h2vj65r8595qt9jl4gz84w4mja74tymt8xxaguckeam", - BlockHeight.new(ZcashNetwork.Testnet, 1330000) - ), - // Mainnet wallet - arrayOf( - TestWallet(TestWallet.Backups.SAMPLE_WALLET, ZcashNetwork.Mainnet), - "uview1n8j8hckdh4rpxsa8qswmcv8mgu6g3f4l4se6ympej3qr6k5k5xlw47u02s3h2sy5aplkzuwysvum2p6weakvyc72udsuvplaq8r5jkw5h6cjfp26j8rudam7suzu6lwalzakpps2jv2x5v08gf3la02dtdlq75ca7k4urg6t0yncyly5wu26t6mfdfvxvhckr2qxzcwllnh947gn6wzg92f0mlhfds239q50gm4398n02anm23qgk8st49u0wmmw7flathr49h2twxvfm6gauasuq6z2fvs3t8g9ut4duk7tp7ry88dwacsutxzpwnm674y06mf3mz3tnu8s2fx4vatmcs9", - BlockHeight.new(ZcashNetwork.Mainnet, 1000000) - ) - ) - } -} diff --git a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/integration/SmokeTest.kt b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/integration/SynchronizerFactoryTest.kt similarity index 64% rename from sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/integration/SmokeTest.kt rename to sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/integration/SynchronizerFactoryTest.kt index c32b99ba..8c79faa2 100644 --- a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/integration/SmokeTest.kt +++ b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/integration/SynchronizerFactoryTest.kt @@ -1,29 +1,20 @@ package cash.z.ecc.android.sdk.integration import androidx.test.core.app.ApplicationProvider -import androidx.test.filters.LargeTest -import androidx.test.filters.MediumTest +import androidx.test.filters.SmallTest import cash.z.ecc.android.sdk.DefaultSynchronizerFactory -import cash.z.ecc.android.sdk.annotation.MaintainedTest -import cash.z.ecc.android.sdk.annotation.TestPurpose import cash.z.ecc.android.sdk.internal.SaplingParamTool import cash.z.ecc.android.sdk.internal.db.DatabaseCoordinator import cash.z.ecc.android.sdk.model.ZcashNetwork import cash.z.ecc.android.sdk.util.TestWallet import kotlinx.coroutines.runBlocking import org.junit.Assert.assertTrue -import org.junit.Ignore import org.junit.Test -/** - * This test is intended to run to make sure that basic things are functional and pinpoint what is - * not working. It was originally developed after a major refactor to find what broke. - */ -@MaintainedTest(TestPurpose.COMMIT) -@MediumTest -class SmokeTest { +class SynchronizerFactoryTest { @Test + @SmallTest fun testFilePaths() { val rustBackend = runBlocking { DefaultSynchronizerFactory.defaultRustBackend( @@ -53,17 +44,4 @@ class SmokeTest { ) ) } - - // This test takes an extremely long time - // Does its runtime grow over time based on growth of the blockchain? - @Test - @LargeTest - @Ignore("This test is extremely slow and times out before the timeout given") - fun testSync() = runBlocking { - wallet.sync(300_000L) - } - - companion object { - val wallet = TestWallet(TestWallet.Backups.SAMPLE_WALLET) - } } diff --git a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/integration/TestnetIntegrationTest.kt b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/integration/TestnetIntegrationTest.kt index 7706bd98..71449b59 100644 --- a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/integration/TestnetIntegrationTest.kt +++ b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/integration/TestnetIntegrationTest.kt @@ -144,7 +144,6 @@ class TestnetIntegrationTest : ScopedTest() { seed = seed, birthday = BlockHeight.new(ZcashNetwork.Testnet, birthdayHeight) ) - synchronizer.start(classScope) } } } diff --git a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/SdkSynchronizerTest.kt b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/SdkSynchronizerTest.kt new file mode 100644 index 00000000..1008482c --- /dev/null +++ b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/internal/SdkSynchronizerTest.kt @@ -0,0 +1,74 @@ +package cash.z.ecc.android.sdk.internal + +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import cash.z.ecc.android.bip39.Mnemonics +import cash.z.ecc.android.sdk.Synchronizer +import cash.z.ecc.android.sdk.fixture.WalletFixture +import cash.z.ecc.android.sdk.model.LightWalletEndpoint +import cash.z.ecc.android.sdk.model.ZcashNetwork +import cash.z.ecc.android.sdk.model.defaultForNetwork +import kotlinx.coroutines.test.runTest +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class SdkSynchronizerTest { + + @Test + @SmallTest + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + fun cannot_instantiate_in_parallel() = runTest { + // Random alias so that repeated invocations of this test will have a clean starting state + val alias = UUID.randomUUID().toString() + + // In the future, inject fake networking component so that it doesn't require hitting the network + Synchronizer.new( + InstrumentationRegistry.getInstrumentation().context, + ZcashNetwork.Mainnet, + alias, + LightWalletEndpoint.defaultForNetwork(ZcashNetwork.Mainnet), + Mnemonics.MnemonicCode(WalletFixture.SEED_PHRASE).toEntropy(), + birthday = null + ).use { + assertFailsWith { + Synchronizer.new( + InstrumentationRegistry.getInstrumentation().context, + ZcashNetwork.Mainnet, + alias, + LightWalletEndpoint.defaultForNetwork(ZcashNetwork.Mainnet), + Mnemonics.MnemonicCode(WalletFixture.SEED_PHRASE).toEntropy(), + birthday = null + ) + } + } + } + + @Test + @SmallTest + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + fun can_instantiate_in_serial() = runTest { + // Random alias so that repeated invocations of this test will have a clean starting state + val alias = UUID.randomUUID().toString() + + // In the future, inject fake networking component so that it doesn't require hitting the network + Synchronizer.new( + InstrumentationRegistry.getInstrumentation().context, + ZcashNetwork.Mainnet, + alias, + LightWalletEndpoint.defaultForNetwork(ZcashNetwork.Mainnet), + Mnemonics.MnemonicCode(WalletFixture.SEED_PHRASE).toEntropy(), + birthday = null + ).use {} + + // Second instance should succeed because first one was closed + Synchronizer.new( + InstrumentationRegistry.getInstrumentation().context, + ZcashNetwork.Mainnet, + alias, + LightWalletEndpoint.defaultForNetwork(ZcashNetwork.Mainnet), + Mnemonics.MnemonicCode(WalletFixture.SEED_PHRASE).toEntropy(), + birthday = null + ).use {} + } +} diff --git a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/util/BalancePrinterUtil.kt b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/util/BalancePrinterUtil.kt index 94aa6931..af6a416b 100644 --- a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/util/BalancePrinterUtil.kt +++ b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/util/BalancePrinterUtil.kt @@ -1,6 +1,7 @@ package cash.z.ecc.android.sdk.util import androidx.test.platform.app.InstrumentationRegistry +import cash.z.ecc.android.sdk.CloseableSynchronizer import cash.z.ecc.android.sdk.Synchronizer import cash.z.ecc.android.sdk.internal.TroubleshootingTwig import cash.z.ecc.android.sdk.internal.Twig @@ -46,7 +47,7 @@ class BalancePrinterUtil { // private val rustBackend = RustBackend.init(context, cacheDbName, dataDbName) private lateinit var birthday: Checkpoint - private var synchronizer: Synchronizer? = null + private var synchronizer: CloseableSynchronizer? = null @Before fun setup() { @@ -94,7 +95,7 @@ class BalancePrinterUtil { - I might need to consider how state is impacting this design - can we be more stateless and thereby improve the flexibility of this code?!!! */ - synchronizer?.stop() + synchronizer?.close() synchronizer = Synchronizer.new( context, network, @@ -102,9 +103,7 @@ class BalancePrinterUtil { .defaultForNetwork(network), seed = seed, birthday = birthdayHeight - ).apply { - start() - } + ) // deleteDb(dataDbPath) // initWallet(seed) diff --git a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/util/DataDbScannerUtil.kt b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/util/DataDbScannerUtil.kt index 25ce54d0..b69498df 100644 --- a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/util/DataDbScannerUtil.kt +++ b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/util/DataDbScannerUtil.kt @@ -1,6 +1,7 @@ package cash.z.ecc.android.sdk.util import androidx.test.platform.app.InstrumentationRegistry +import cash.z.ecc.android.sdk.CloseableSynchronizer import cash.z.ecc.android.sdk.SdkSynchronizer import cash.z.ecc.android.sdk.Synchronizer import cash.z.ecc.android.sdk.internal.TroubleshootingTwig @@ -40,7 +41,7 @@ class DataDbScannerUtil { // private val rustBackend = RustBackend.init(context, cacheDbName, dataDbName) private val birthdayHeight = 600_000L - private lateinit var synchronizer: Synchronizer + private lateinit var synchronizer: CloseableSynchronizer @Before fun setup() { @@ -79,7 +80,6 @@ class DataDbScannerUtil { ) println("sync!") - synchronizer.start() val scope = (synchronizer as SdkSynchronizer).coroutineScope scope.launch { @@ -92,7 +92,7 @@ class DataDbScannerUtil { println("going to sleep!") Thread.sleep(125000) println("I'm back and I'm out!") - synchronizer.stop() + runBlocking { synchronizer.close() } } // // @Test diff --git a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/util/TestWallet.kt b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/util/TestWallet.kt index 55d2f0a5..42b6cf41 100644 --- a/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/util/TestWallet.kt +++ b/sdk-lib/src/androidTest/java/cash/z/ecc/android/sdk/util/TestWallet.kt @@ -95,12 +95,8 @@ class TestWallet( throw TimeoutException("Failed to sync wallet within ${timeout}ms") } } - if (!synchronizer.isStarted) { - twig("Starting sync") - synchronizer.start(walletScope) - } else { - twig("Awaiting next SYNCED status") - } + + twig("Awaiting next SYNCED status") // block until synced synchronizer.status.first { it == Synchronizer.Status.SYNCED } @@ -156,7 +152,7 @@ class TestWallet( twig("Scheduling a stop in ${timeout}ms") walletScope.launch { delay(timeout) - synchronizer.stop() + synchronizer.close() } } synchronizer.status.first { it == Synchronizer.Status.STOPPED } diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/SdkSynchronizer.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/SdkSynchronizer.kt index a51d19f5..cdf1178d 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/SdkSynchronizer.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/SdkSynchronizer.kt @@ -17,7 +17,6 @@ import cash.z.ecc.android.sdk.block.CompactBlockProcessor.State.Scanned import cash.z.ecc.android.sdk.block.CompactBlockProcessor.State.Scanning import cash.z.ecc.android.sdk.block.CompactBlockProcessor.State.Stopped import cash.z.ecc.android.sdk.block.CompactBlockProcessor.State.Validating -import cash.z.ecc.android.sdk.exception.SynchronizerException import cash.z.ecc.android.sdk.ext.ConsensusBranchId import cash.z.ecc.android.sdk.ext.ZcashSdk import cash.z.ecc.android.sdk.internal.SaplingParamTool @@ -85,8 +84,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import java.io.File +import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext /** * A Synchronizer that attempts to remain operational, despite any number of errors that can occur. @@ -96,18 +95,90 @@ import kotlin.coroutines.EmptyCoroutineContext * pieces can be tied together. Its goal is to allow a developer to focus on their app rather than * the nuances of how Zcash works. * + * @property synchronizerKey Identifies the synchronizer's on-disk state * @property storage exposes flows of wallet transaction information. * @property txManager manages and tracks outbound transactions. * @property processor saves the downloaded compact blocks to the cache and then scans those blocks for * data related to this wallet. */ @Suppress("TooManyFunctions") -class SdkSynchronizer internal constructor( +class SdkSynchronizer private constructor( + private val synchronizerKey: SynchronizerKey, private val storage: DerivedDataRepository, private val txManager: OutboundTransactionManager, val processor: CompactBlockProcessor, private val rustBackend: RustBackend -) : Synchronizer { +) : CloseableSynchronizer { + + companion object { + private sealed class InstanceState { + object Active : InstanceState() + data class ShuttingDown(val job: Job) : InstanceState() + } + + private val instances: MutableMap = + ConcurrentHashMap() + + /** + * @throws IllegalStateException If multiple instances of synchronizer with the same network+alias are + * active at the same time. Call `close` to finish one synchronizer before starting another one with the same + * network+alias. + */ + @Suppress("LongParameterList") + internal suspend fun new( + zcashNetwork: ZcashNetwork, + alias: String, + repository: DerivedDataRepository, + txManager: OutboundTransactionManager, + processor: CompactBlockProcessor, + rustBackend: RustBackend + ): CloseableSynchronizer { + val synchronizerKey = SynchronizerKey(zcashNetwork, alias) + + waitForShutdown(synchronizerKey) + checkForExistingSynchronizers(synchronizerKey) + + return SdkSynchronizer( + synchronizerKey, + repository, + txManager, + processor, + rustBackend + ).apply { + instances[synchronizerKey] = InstanceState.Active + + start() + } + } + + private suspend fun waitForShutdown(synchronizerKey: SynchronizerKey) { + instances[synchronizerKey]?.let { + if (it is InstanceState.ShuttingDown) { + twig("Waiting for prior synchronizer instance to shut down") // $NON-NLS-1$ + it.job.join() + } + } + } + + private fun checkForExistingSynchronizers(synchronizerKey: SynchronizerKey) { + check(!instances.containsKey(synchronizerKey)) { + "Another synchronizer with $synchronizerKey is currently active" // $NON-NLS-1$ + } + } + + internal suspend fun erase( + appContext: Context, + network: ZcashNetwork, + alias: String + ): Boolean { + val key = SynchronizerKey(network, alias) + + waitForShutdown(key) + checkForExistingSynchronizers(key) + + return DatabaseCoordinator.getInstance(appContext).deleteDatabases(network, alias) + } + } // pools private val _orchardBalances = MutableStateFlow(null) @@ -116,25 +187,7 @@ class SdkSynchronizer internal constructor( private val _status = MutableStateFlow(DISCONNECTED) - /** - * The lifespan of this Synchronizer. This scope is initialized once the Synchronizer starts - * because it will be a child of the parentScope that gets passed into the [start] function. - * Everything launched by this Synchronizer will be cancelled once the Synchronizer or its - * parentScope stops. This coordinates with [isStarted] so that it fails early - * rather than silently, whenever the scope is used before the Synchronizer has been started. - */ - var coroutineScope: CoroutineScope = CoroutineScope(EmptyCoroutineContext) - get() { - if (!isStarted) { - throw SynchronizerException.NotYetStarted - } else { - return field - } - } - set(value) { - field = value - if (value.coroutineContext !is EmptyCoroutineContext) isStarted = true - } + var coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) /** * The channel that this Synchronizer uses to communicate with lightwalletd. In most cases, this @@ -145,8 +198,6 @@ class SdkSynchronizer internal constructor( */ val channel: ManagedChannel get() = (processor.downloader.lightWalletService as LightWalletGrpcService).channel - override var isStarted = false - // // Balances // @@ -256,54 +307,31 @@ class SdkSynchronizer internal constructor( override val latestBirthdayHeight get() = processor.birthdayHeight - override suspend fun prepare(): Synchronizer = apply { - // Do nothing; this could likely be removed + internal fun start() { + coroutineScope.onReady() } - /** - * Starts this synchronizer within the given scope. For simplicity, attempting to start an - * instance that has already been started will throw a [SynchronizerException.FalseStart] - * exception. This reduces the complexity of managing resources that must be recycled. Instead, - * each synchronizer is designed to have a long lifespan and should be started from an activity, - * application or session. - * - * @param parentScope the scope to use for this synchronizer, typically something with a - * lifecycle such as an Activity for single-activity apps or a logged in user session. This - * scope is only used for launching this synchronizer's job as a child. If no scope is provided, - * then this synchronizer and all of its coroutines will run until stop is called, which is not - * recommended since it can leak resources. That type of behavior is more useful for tests. - * - * @return an instance of this class so that this function can be used fluidly. - */ - override fun start(parentScope: CoroutineScope?): Synchronizer { - if (isStarted) throw SynchronizerException.FalseStart - // base this scope on the parent so that when the parent's job cancels, everything here - // cancels as well also use a supervisor job so that one failure doesn't bring down the - // whole synchronizer - val supervisorJob = SupervisorJob(parentScope?.coroutineContext?.get(Job)) - CoroutineScope(supervisorJob + Dispatchers.Main).let { scope -> - coroutineScope = scope - scope.onReady() - } - return this - } + override fun close() { + // Note that stopping will continue asynchronously. Race conditions with starting a new synchronizer are + // avoided with a delay during startup. - /** - * Stop this synchronizer and all of its child jobs. Once a synchronizer has been stopped it - * should not be restarted and attempting to do so will result in an error. Also, this function - * will throw an exception if the synchronizer was never previously started. - */ - override fun stop() { - coroutineScope.launch { + val shutdownJob = coroutineScope.launch { // log everything to help troubleshoot shutdowns that aren't graceful twig("Synchronizer::stop: STARTING") twig("Synchronizer::stop: processor.stop()") processor.stop() + } + + instances[synchronizerKey] = InstanceState.ShuttingDown(shutdownJob) + + shutdownJob.invokeOnCompletion { twig("Synchronizer::stop: coroutineScope.cancel()") coroutineScope.cancel() twig("Synchronizer::stop: _status.cancel()") _status.value = STOPPED twig("Synchronizer::stop: COMPLETE") + + instances.remove(synchronizerKey) } } @@ -407,7 +435,6 @@ class SdkSynchronizer internal constructor( private fun CoroutineScope.onReady() = launch(CoroutineExceptionHandler(::onCriticalError)) { twig("Preparing to start...") - prepare() twig("Synchronizer (${this@SdkSynchronizer}) Ready. Starting processor!") var lastScanTime = 0L @@ -725,25 +752,6 @@ class SdkSynchronizer internal constructor( serverBranchId?.let { ConsensusBranchId.fromHex(it) } ) } - - interface Erasable { - /** - * Erase content related to this SDK. - * - * @param appContext the application context. - * @param network the network corresponding to the data being erased. Data is segmented by - * network in order to prevent contamination. - * @param alias identifier for SDK content. It is possible for multiple synchronizers to - * exist with different aliases. - * - * @return true when content was found for the given alias. False otherwise. - */ - suspend fun erase( - appContext: Context, - network: ZcashNetwork, - alias: String = ZcashSdk.DEFAULT_ALIAS - ): Boolean - } } /** @@ -753,20 +761,6 @@ class SdkSynchronizer internal constructor( */ internal object DefaultSynchronizerFactory { - fun new( - repository: DerivedDataRepository, - txManager: OutboundTransactionManager, - processor: CompactBlockProcessor, - rustBackend: RustBackend - ): Synchronizer { - return SdkSynchronizer( - repository, - txManager, - processor, - rustBackend - ) - } - internal suspend fun defaultRustBackend( context: Context, network: ZcashNetwork, @@ -850,3 +844,5 @@ internal object DefaultSynchronizerFactory { rustBackend.birthdayHeight ) } + +internal data class SynchronizerKey(val zcashNetwork: ZcashNetwork, val alias: String) diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/Synchronizer.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/Synchronizer.kt index 9750cd8f..36819a89 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/Synchronizer.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/Synchronizer.kt @@ -21,10 +21,10 @@ import cash.z.ecc.android.sdk.tool.DerivationTool import cash.z.ecc.android.sdk.type.AddressType import cash.z.ecc.android.sdk.type.ConsensusMatchType import cash.z.wallet.sdk.rpc.Service -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.runBlocking +import java.io.Closeable /** * Primary interface for interacting with the SDK. Defines the contract that specific @@ -35,45 +35,6 @@ import kotlinx.coroutines.runBlocking @Suppress("TooManyFunctions") interface Synchronizer { - // - // Lifecycle - // - - /** - * Return true when this synchronizer has been started. - */ - var isStarted: Boolean - - /** - * Prepare the synchronizer to start. Must be called before start. This gives a clear point - * where setup and maintenance can occur for various Synchronizers. One that uses a database - * would take this opportunity to do data migrations or key migrations. - */ - suspend fun prepare(): Synchronizer - - /** - * Starts this synchronizer within the given scope. - * - * @param parentScope the scope to use for this synchronizer, typically something with a - * lifecycle such as an Activity. Implementations should leverage structured concurrency and - * cancel all jobs when this scope completes. - * - * @return an instance of the class so that this function can be used fluidly. - */ - fun start(parentScope: CoroutineScope? = null): Synchronizer - - /** - * Stop this synchronizer. Implementations should ensure that calling this method cancels all - * jobs that were created by this instance. - * - * Note that in most cases, there is no need to call [stop] because the Synchronizer will - * automatically stop whenever the parentScope is cancelled. For instance, if that scope is - * bound to the lifecycle of the activity, the Synchronizer will stop when the activity stops. - * However, if no scope is provided to the start method, then the Synchronizer must be stopped - * with this function. - */ - fun stop() - // // Flows // @@ -495,6 +456,9 @@ interface Synchronizer { * sync times. After sync completes, the birthday can be determined from [Synchronizer.latestBirthdayHeight]. * @throws InitializerException.SeedRequired Indicates clients need to call this method again, providing the * seed bytes. + * @throws IllegalStateException If multiple instances of synchronizer with the same network+alias are + * active at the same time. Call `close` to finish one synchronizer before starting another one with the same + * network+alias. */ /* * If customized initialization is required (e.g. for dependency injection or testing), see @@ -508,7 +472,7 @@ interface Synchronizer { lightWalletEndpoint: LightWalletEndpoint, seed: ByteArray?, birthday: BlockHeight? - ): Synchronizer { + ): CloseableSynchronizer { val applicationContext = context.applicationContext validateAlias(alias) @@ -566,7 +530,9 @@ interface Synchronizer { ) val processor = DefaultSynchronizerFactory.defaultProcessor(rustBackend, downloader, repository) - return SdkSynchronizer( + return SdkSynchronizer.new( + zcashNetwork, + alias, repository, txManager, processor, @@ -589,7 +555,7 @@ interface Synchronizer { lightWalletEndpoint: LightWalletEndpoint, seed: ByteArray?, birthday: BlockHeight? - ): Synchronizer = runBlocking { + ): CloseableSynchronizer = runBlocking { new(context, zcashNetwork, alias, lightWalletEndpoint, seed, birthday) } @@ -611,10 +577,12 @@ interface Synchronizer { appContext: Context, network: ZcashNetwork, alias: String = ZcashSdk.DEFAULT_ALIAS - ): Boolean = DatabaseCoordinator.getInstance(appContext).deleteDatabases(network, alias) + ): Boolean = SdkSynchronizer.erase(appContext, network, alias) } } +interface CloseableSynchronizer : Synchronizer, Closeable + /** * Validate that the alias doesn't contain malicious characters by enforcing simple rules which * permit the alias to be used as part of a file name for the preferences and databases. This @@ -623,15 +591,14 @@ interface Synchronizer { * @param alias the alias to validate. * * @throws IllegalArgumentException whenever the alias is not less than 100 characters or - * contains something other than alphanumeric characters. Underscores are allowed but aliases - * must start with a letter. + * contains something other than alphanumeric characters. Underscores and hyphens are allowed. */ private fun validateAlias(alias: String) { require( - alias.length in ZcashSdk.ALIAS_MIN_LENGTH..ZcashSdk.ALIAS_MAX_LENGTH && alias[0].isLetter() && - alias.all { it.isLetterOrDigit() || it == '_' } + alias.length in ZcashSdk.ALIAS_MIN_LENGTH..ZcashSdk.ALIAS_MAX_LENGTH && + alias.all { it.isLetterOrDigit() || it == '_' || it == '-' } ) { "ERROR: Invalid alias ($alias). For security, the alias must be shorter than 100 " + - "characters and only contain letters, digits or underscores and start with a letter." + "characters and only contain letters, digits, hyphens, and underscores." } }