[#742] Prevent multiple synchronizer instances

Co-authored-by: Honza <rychnovsky.honza@gmail.com>
This commit is contained in:
Carter Jernigan 2022-12-14 10:33:18 -05:00 committed by GitHub
parent 97c0628798
commit 2ab6d038ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 573 additions and 738 deletions

View File

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

View File

@ -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<Synchronizer> = callbackFlow<Synchronizer> {
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.

View File

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

View File

@ -42,7 +42,6 @@ class ReorgSetupTest : ScopedTest() {
@JvmStatic
fun startOnce() {
sithLord.enterTheDarkside()
sithLord.synchronizer.start(classScope)
}
}
}

View File

@ -60,7 +60,6 @@ class ReorgSmallTest : ScopedTest() {
validator.onReorg { _, _ ->
hadReorg = true
}
sithLord.synchronizer.start(classScope)
}
}
}

View File

@ -7,7 +7,6 @@ open class DarksideTest : ScopedTest() {
fun runOnce(block: () -> Unit) {
if (!ranOnce) {
sithLord.enterTheDarkside()
sithLord.synchronizer.start(classScope)
block()
ranOnce = true
}

View File

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

View File

@ -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<BlockHeight?> get() = _blockHeight
private val lockoutMutex = Mutex()
private val lockoutIdFlow = MutableStateFlow<UUID?>(null)
@OptIn(ExperimentalCoroutinesApi::class)
private val synchronizerOrLockout: Flow<InternalSynchronizerStatus> = lockoutIdFlow.flatMapLatest { lockoutId ->
if (null != lockoutId) {
flowOf(InternalSynchronizerStatus.Lockout(lockoutId))
} else {
callbackFlow<InternalSynchronizerStatus> {
// 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<Synchronizer?> = 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<Application>()) {
Synchronizer.erase(
appContext = applicationContext,
network = ZcashNetwork.fromResources(applicationContext)
)
lockoutMutex.withLock {
val lockoutId = UUID.randomUUID()
lockoutIdFlow.value = lockoutId
synchronizerOrLockout
.filterIsInstance<InternalSynchronizerStatus.Lockout>()
.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
}
}

View File

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

View File

@ -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<FragmentGetBalanceBinding>() {
private lateinit var synchronizer: Synchronizer
override fun inflateBinding(layoutInflater: LayoutInflater): FragmentGetBalanceBinding =
FragmentGetBalanceBinding.inflate(layoutInflater)
@ -46,7 +44,6 @@ class GetBalanceFragment : BaseDemoFragment<FragmentGetBalanceBinding>() {
super.onCreate(savedInstanceState)
reportTraceEvent(SyncBlockchainBenchmarkTrace.Event.BALANCE_SCREEN_START)
setHasOptionsMenu(true)
setup()
}
override fun onPrepareOptionsMenu(menu: Menu) {
@ -60,28 +57,6 @@ class GetBalanceFragment : BaseDemoFragment<FragmentGetBalanceBinding>() {
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<FragmentGetBalanceBinding>() {
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<FragmentGetBalanceBinding>() {
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")

View File

@ -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<FragmentListTransactionsBinding>() {
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<FragmentListTransactionsBindin
binding.recyclerTransactions.adapter = adapter
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun monitorChanges() {
// the lifecycleScope is used to stop everything when the fragment dies
synchronizer.status.collectWith(lifecycleScope, ::onStatus)
synchronizer.processorInfo.collectWith(lifecycleScope, ::onProcessorInfoUpdated)
synchronizer.progress.collectWith(lifecycleScope, ::onProgress)
synchronizer.clearedTransactions.collectWith(lifecycleScope) {
onTransactionsUpdated(it)
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) }
}
}
}
}
@ -139,19 +125,10 @@ class ListTransactionsFragment : BaseDemoFragment<FragmentListTransactionsBindin
setHasOptionsMenu(true)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)
setup()
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initTransactionUI()
monitorChanges()
}
override fun onPrepareOptionsMenu(menu: Menu) {
@ -160,13 +137,6 @@ class ListTransactionsFragment : BaseDemoFragment<FragmentListTransactionsBindin
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()
}
//
// Base Fragment overrides
//

View File

@ -4,10 +4,10 @@ import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
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.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
@ -16,17 +16,16 @@ import cash.z.ecc.android.sdk.demoapp.databinding.FragmentListUtxosBinding
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.internal.twig
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.max
@ -45,8 +44,6 @@ import kotlin.math.max
*/
@Suppress("TooManyFunctions")
class ListUtxosFragment : BaseDemoFragment<FragmentListUtxosBinding>() {
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<FragmentListUtxosBinding>() {
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<FragmentListUtxosBinding>() {
}
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<FragmentListUtxosBinding>() {
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<FragmentListUtxosBinding>() {
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()}")
}
}
}
}

View File

@ -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<FragmentSendBinding>() {
private lateinit var synchronizer: Synchronizer
private lateinit var amountInput: TextView
private lateinit var addressInput: TextView
@ -60,35 +54,6 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
// 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<FragmentSendBinding>() {
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<FragmentSendBinding>() {
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<FragmentSendBinding>() {
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<FragmentSendBinding>() {
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
//

View File

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

View File

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

View File

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

View File

@ -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<Unit> {
wallet.sync(300_000L)
}
companion object {
val wallet = TestWallet(TestWallet.Backups.SAMPLE_WALLET)
}
}

View File

@ -144,7 +144,6 @@ class TestnetIntegrationTest : ScopedTest() {
seed = seed,
birthday = BlockHeight.new(ZcashNetwork.Testnet, birthdayHeight)
)
synchronizer.start(classScope)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<SynchronizerKey, InstanceState> =
ConcurrentHashMap<SynchronizerKey, InstanceState>()
/**
* @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<WalletBalance?>(null)
@ -116,25 +187,7 @@ class SdkSynchronizer internal constructor(
private val _status = MutableStateFlow<Synchronizer.Status>(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)

View File

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