Demo app improvements.
including: mainnet support, BIP39 support and using seed words instead of string-based seeds, several convenience functions like copyToClipboard, simplified the developer experience for using the FAB, corrected errors in port numbers, streamlined several demos, trim user input, better messaging using new processorInfo flow.
This commit is contained in:
parent
250217d0cd
commit
508e6d3da9
|
@ -15,7 +15,21 @@ android {
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "1.0"
|
versionName "1.0"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
missingDimensionStrategy "network", "zcashtestnet"
|
}
|
||||||
|
flavorDimensions 'network'
|
||||||
|
productFlavors {
|
||||||
|
// would rather name them "testnet" and "mainnet" but product flavor names cannot start with the word "test"
|
||||||
|
zcashtestnet {
|
||||||
|
dimension 'network'
|
||||||
|
applicationId 'cash.z.wallet.sdk.demoapp.testnet'
|
||||||
|
matchingFallbacks = ['zcashtestnet', 'debug']
|
||||||
|
}
|
||||||
|
|
||||||
|
zcashmainnet {
|
||||||
|
dimension 'network'
|
||||||
|
applicationId 'cash.z.wallet.sdk.demoapp.mainnet'
|
||||||
|
matchingFallbacks = ['zcashmainnet', 'release']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
|
@ -36,7 +50,12 @@ dependencies {
|
||||||
// SDK
|
// SDK
|
||||||
implementation project(path: ':sdk')
|
implementation project(path: ':sdk')
|
||||||
// implementation "cash.z.android.wallet:zcash-android-core:$sdk_version@aar"
|
// implementation "cash.z.android.wallet:zcash-android-core:$sdk_version@aar"
|
||||||
// implementation "cash.z.android.wallet:zcash-kotlin-bip39:$sdk_version@aar"
|
|
||||||
|
// sample mnemonic plugin
|
||||||
|
implementation 'com.github.zcash:zcash-android-wallet-plugins:1.0.0'
|
||||||
|
implementation 'com.madgag.spongycastle:core:1.58.0.0'
|
||||||
|
implementation 'io.github.novacrypto:BIP39:2019.01.27'
|
||||||
|
implementation 'io.github.novacrypto:securestring:2019.01.27'
|
||||||
|
|
||||||
// SDK: grpc
|
// SDK: grpc
|
||||||
implementation "io.grpc:grpc-okhttp:1.21.0"
|
implementation "io.grpc:grpc-okhttp:1.21.0"
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package cash.z.wallet.sdk.demoapp
|
package cash.z.wallet.sdk.demoapp
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import cash.z.wallet.sdk.demoapp.util.DemoConfig
|
|
||||||
|
|
||||||
class App : Application() {
|
class App : Application() {
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,19 @@
|
||||||
package cash.z.wallet.sdk.demoapp
|
package cash.z.wallet.sdk.demoapp
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import cash.z.wallet.sdk.ext.TroubleshootingTwig
|
import cash.z.wallet.sdk.ext.TroubleshootingTwig
|
||||||
import cash.z.wallet.sdk.ext.Twig
|
import cash.z.wallet.sdk.ext.Twig
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import kotlinx.coroutines.Dispatchers.IO
|
import kotlinx.coroutines.Dispatchers.IO
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@ -32,6 +37,7 @@ abstract class BaseDemoFragment<T : ViewBinding> : Fragment() {
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
registerActionButtonListener()
|
||||||
// just a quick way of enforcing the following for each demo:
|
// just a quick way of enforcing the following for each demo:
|
||||||
// - wait until the fragment is created, then run `initInBackground` on a background thread
|
// - wait until the fragment is created, then run `initInBackground` on a background thread
|
||||||
// - wait until init is finished
|
// - wait until init is finished
|
||||||
|
@ -59,9 +65,58 @@ abstract class BaseDemoFragment<T : ViewBinding> : Fragment() {
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
|
unregisterActionButtonListener()
|
||||||
onClear()
|
onClear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun registerActionButtonListener() {
|
||||||
|
(activity as? MainActivity)?.fabListener = this
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterActionButtonListener() {
|
||||||
|
(activity as? MainActivity)?.apply {
|
||||||
|
if (fabListener === this@BaseDemoFragment) fabListener = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback to run whenever the fragment is paused. The intention is to clear out each demo
|
||||||
|
* cleanly so that they are always a repeatable experience.
|
||||||
|
*/
|
||||||
|
open fun onClear() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback that gets invoked on the visible fragment whenever the floating action button is
|
||||||
|
* tapped. This provides a convenient placeholder for the developer to extend the
|
||||||
|
* behavior for a demo, for instance by copying the address to the clipboard, whenever the FAB
|
||||||
|
* is tapped on the address screen.
|
||||||
|
*/
|
||||||
|
open fun onActionButtonClicked() {
|
||||||
|
// Show a message so that it's easy for developers to find how to replace this behavior for
|
||||||
|
// each fragment. Simply override this [onActionButtonClicked] callback to add behavior to a
|
||||||
|
// demo. In other words, this function probably doesn't need to change because desired
|
||||||
|
// behavior should go in the child fragment, which overrides this.
|
||||||
|
Snackbar.make(view!!, "Replace with your own action", Snackbar.LENGTH_LONG)
|
||||||
|
.setAction("Action") { /* auto-close */ }.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to the given text to the clipboard.
|
||||||
|
*/
|
||||||
|
open fun copyToClipboard(text: String) {
|
||||||
|
(activity?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager)?.let { cm ->
|
||||||
|
cm.setPrimaryClip(ClipData.newPlainText("DemoAppClip", text))
|
||||||
|
}
|
||||||
|
toast("Copied to clipboard!")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to show a toast in the main activity.
|
||||||
|
*/
|
||||||
|
fun toast(message: String) {
|
||||||
|
Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inflate the ViewBinding. Unfortunately, the `inflate` function is not part of the ViewBinding
|
* Inflate the ViewBinding. Unfortunately, the `inflate` function is not part of the ViewBinding
|
||||||
* interface so the base class cannot take care of this behavior without some help.
|
* interface so the base class cannot take care of this behavior without some help.
|
||||||
|
@ -69,5 +124,4 @@ abstract class BaseDemoFragment<T : ViewBinding> : Fragment() {
|
||||||
abstract fun inflateBinding(layoutInflater: LayoutInflater): T
|
abstract fun inflateBinding(layoutInflater: LayoutInflater): T
|
||||||
abstract fun resetInBackground()
|
abstract fun resetInBackground()
|
||||||
abstract fun onResetComplete()
|
abstract fun onResetComplete()
|
||||||
abstract fun onClear()
|
|
||||||
}
|
}
|
|
@ -2,6 +2,7 @@ package cash.z.wallet.sdk.demoapp
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
|
import android.view.View
|
||||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.google.android.material.navigation.NavigationView
|
import com.google.android.material.navigation.NavigationView
|
||||||
|
@ -13,11 +14,12 @@ import androidx.navigation.ui.setupWithNavController
|
||||||
import androidx.drawerlayout.widget.DrawerLayout
|
import androidx.drawerlayout.widget.DrawerLayout
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.widget.Toolbar
|
import androidx.appcompat.widget.Toolbar
|
||||||
import cash.z.wallet.sdk.demoapp.util.DemoConfig
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.viewbinding.ViewBinding
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private lateinit var appBarConfiguration: AppBarConfiguration
|
private lateinit var appBarConfiguration: AppBarConfiguration
|
||||||
|
var fabListener: BaseDemoFragment<out ViewBinding>? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
@ -27,8 +29,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
val fab: FloatingActionButton = findViewById(R.id.fab)
|
val fab: FloatingActionButton = findViewById(R.id.fab)
|
||||||
fab.setOnClickListener { view ->
|
fab.setOnClickListener { view ->
|
||||||
Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
|
onFabClicked(view)
|
||||||
.setAction("Action", null).show()
|
|
||||||
}
|
}
|
||||||
val drawerLayout: DrawerLayout = findViewById(R.id.drawer_layout)
|
val drawerLayout: DrawerLayout = findViewById(R.id.drawer_layout)
|
||||||
val navView: NavigationView = findViewById(R.id.nav_view)
|
val navView: NavigationView = findViewById(R.id.nav_view)
|
||||||
|
@ -46,6 +47,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
navView.setupWithNavController(navController)
|
navView.setupWithNavController(navController)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onFabClicked(view: View) {
|
||||||
|
fabListener?.onActionButtonClicked()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
// Inflate the menu; this adds items to the action bar if it is present.
|
// Inflate the menu; this adds items to the action bar if it is present.
|
||||||
menuInflater.inflate(R.menu.main, menu)
|
menuInflater.inflate(R.menu.main, menu)
|
||||||
|
|
|
@ -8,27 +8,24 @@ import cash.z.wallet.sdk.demoapp.databinding.FragmentGetAddressBinding
|
||||||
|
|
||||||
class GetAddressFragment : BaseDemoFragment<FragmentGetAddressBinding>() {
|
class GetAddressFragment : BaseDemoFragment<FragmentGetAddressBinding>() {
|
||||||
|
|
||||||
private val config = App.instance.defaultConfig
|
private var seed: ByteArray = App.instance.defaultConfig.seed
|
||||||
private var seed: ByteArray = config.seed
|
private val initializer: Initializer = Initializer(App.instance)
|
||||||
private val initializer: Initializer = Initializer(App.instance, host = config.host, port = config.port)
|
|
||||||
private val birthday = config.newWalletBirthday()
|
private lateinit var address: String
|
||||||
|
|
||||||
override fun inflateBinding(layoutInflater: LayoutInflater): FragmentGetAddressBinding
|
override fun inflateBinding(layoutInflater: LayoutInflater): FragmentGetAddressBinding
|
||||||
= FragmentGetAddressBinding.inflate(layoutInflater)
|
= FragmentGetAddressBinding.inflate(layoutInflater)
|
||||||
|
|
||||||
override fun resetInBackground() {
|
override fun resetInBackground() {
|
||||||
/**
|
address = initializer.deriveAddress(seed)
|
||||||
* Create and initialize the wallet. Initialization will return the private keys but for the
|
|
||||||
* purposes of this demo we don't need them.
|
|
||||||
*/
|
|
||||||
initializer.new(seed, birthday)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResetComplete() {
|
override fun onResetComplete() {
|
||||||
binding.textInfo.text = initializer.rustBackend.getAddress()
|
binding.textInfo.text = address
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClear() {
|
override fun onActionButtonClicked() {
|
||||||
initializer.clear()
|
copyToClipboard(address)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -10,6 +10,7 @@ import cash.z.wallet.sdk.service.LightWalletService
|
||||||
|
|
||||||
class GetBlockFragment : BaseDemoFragment<FragmentGetBlockBinding>() {
|
class GetBlockFragment : BaseDemoFragment<FragmentGetBlockBinding>() {
|
||||||
private val host = App.instance.defaultConfig.host
|
private val host = App.instance.defaultConfig.host
|
||||||
|
private val port = App.instance.defaultConfig.port
|
||||||
|
|
||||||
private lateinit var lightwalletService: LightWalletService
|
private lateinit var lightwalletService: LightWalletService
|
||||||
|
|
||||||
|
@ -17,7 +18,7 @@ class GetBlockFragment : BaseDemoFragment<FragmentGetBlockBinding>() {
|
||||||
FragmentGetBlockBinding.inflate(layoutInflater)
|
FragmentGetBlockBinding.inflate(layoutInflater)
|
||||||
|
|
||||||
override fun resetInBackground() {
|
override fun resetInBackground() {
|
||||||
lightwalletService = LightWalletGrpcService(App.instance, host)
|
lightwalletService = LightWalletGrpcService(App.instance, host, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResetComplete() {
|
override fun onResetComplete() {
|
||||||
|
|
|
@ -11,6 +11,7 @@ import cash.z.wallet.sdk.service.LightWalletService
|
||||||
class GetBlockRangeFragment : BaseDemoFragment<FragmentGetBlockRangeBinding>() {
|
class GetBlockRangeFragment : BaseDemoFragment<FragmentGetBlockRangeBinding>() {
|
||||||
|
|
||||||
private val host = App.instance.defaultConfig.host
|
private val host = App.instance.defaultConfig.host
|
||||||
|
private val port = App.instance.defaultConfig.port
|
||||||
|
|
||||||
private lateinit var lightwalletService: LightWalletService
|
private lateinit var lightwalletService: LightWalletService
|
||||||
|
|
||||||
|
@ -18,7 +19,7 @@ class GetBlockRangeFragment : BaseDemoFragment<FragmentGetBlockRangeBinding>() {
|
||||||
FragmentGetBlockRangeBinding.inflate(layoutInflater)
|
FragmentGetBlockRangeBinding.inflate(layoutInflater)
|
||||||
|
|
||||||
override fun resetInBackground() {
|
override fun resetInBackground() {
|
||||||
lightwalletService = LightWalletGrpcService(App.instance, host)
|
lightwalletService = LightWalletGrpcService(App.instance, host, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResetComplete() {
|
override fun onResetComplete() {
|
||||||
|
|
|
@ -10,6 +10,7 @@ import cash.z.wallet.sdk.service.LightWalletService
|
||||||
|
|
||||||
class GetLatestHeightFragment : BaseDemoFragment<FragmentGetLatestHeightBinding>() {
|
class GetLatestHeightFragment : BaseDemoFragment<FragmentGetLatestHeightBinding>() {
|
||||||
private val host = App.instance.defaultConfig.host
|
private val host = App.instance.defaultConfig.host
|
||||||
|
private val port = App.instance.defaultConfig.port
|
||||||
|
|
||||||
private lateinit var lightwalletService: LightWalletService
|
private lateinit var lightwalletService: LightWalletService
|
||||||
|
|
||||||
|
@ -17,7 +18,7 @@ class GetLatestHeightFragment : BaseDemoFragment<FragmentGetLatestHeightBinding>
|
||||||
FragmentGetLatestHeightBinding.inflate(layoutInflater)
|
FragmentGetLatestHeightBinding.inflate(layoutInflater)
|
||||||
|
|
||||||
override fun resetInBackground() {
|
override fun resetInBackground() {
|
||||||
lightwalletService = LightWalletGrpcService(App.instance, host)
|
lightwalletService = LightWalletGrpcService(App.instance, host, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResetComplete() {
|
override fun onResetComplete() {
|
||||||
|
@ -27,4 +28,9 @@ class GetLatestHeightFragment : BaseDemoFragment<FragmentGetLatestHeightBinding>
|
||||||
override fun onClear() {
|
override fun onClear() {
|
||||||
lightwalletService.shutdown()
|
lightwalletService.shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onActionButtonClicked() {
|
||||||
|
toast("Refreshed!")
|
||||||
|
onResetComplete()
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -26,7 +26,7 @@ class GetPrivateKeyFragment : BaseDemoFragment<FragmentGetPrivateKeyBinding>() {
|
||||||
spendingKeys = initializer.new(seed, birthday)
|
spendingKeys = initializer.new(seed, birthday)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Viewing keys can be derived from a seed or from spending keys.
|
* Alternatively, viewing keys can also be derived directly from a seed or spending keys.
|
||||||
*/
|
*/
|
||||||
viewingKeys = initializer.deriveViewingKeys(seed)
|
viewingKeys = initializer.deriveViewingKeys(seed)
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ import androidx.paging.PagedList
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import cash.z.wallet.sdk.Initializer
|
import cash.z.wallet.sdk.Initializer
|
||||||
import cash.z.wallet.sdk.Synchronizer
|
import cash.z.wallet.sdk.Synchronizer
|
||||||
|
import cash.z.wallet.sdk.block.CompactBlockProcessor
|
||||||
import cash.z.wallet.sdk.demoapp.App
|
import cash.z.wallet.sdk.demoapp.App
|
||||||
import cash.z.wallet.sdk.demoapp.BaseDemoFragment
|
import cash.z.wallet.sdk.demoapp.BaseDemoFragment
|
||||||
import cash.z.wallet.sdk.demoapp.databinding.FragmentListTransactionsBinding
|
import cash.z.wallet.sdk.demoapp.databinding.FragmentListTransactionsBinding
|
||||||
|
@ -23,9 +24,13 @@ import kotlinx.coroutines.launch
|
||||||
class ListTransactionsFragment : BaseDemoFragment<FragmentListTransactionsBinding>() {
|
class ListTransactionsFragment : BaseDemoFragment<FragmentListTransactionsBinding>() {
|
||||||
private val config = App.instance.defaultConfig
|
private val config = App.instance.defaultConfig
|
||||||
private val initializer = Initializer(App.instance, host = config.host, port = config.port)
|
private val initializer = Initializer(App.instance, host = config.host, port = config.port)
|
||||||
private val birthday = config.newWalletBirthday()
|
private val birthday = config.loadBirthday()
|
||||||
private lateinit var synchronizer: Synchronizer
|
private lateinit var synchronizer: Synchronizer
|
||||||
private lateinit var adapter: TransactionAdapter<ConfirmedTransaction>
|
private lateinit var adapter: TransactionAdapter<ConfirmedTransaction>
|
||||||
|
private lateinit var address: String
|
||||||
|
private var status: Synchronizer.Status? = null
|
||||||
|
|
||||||
|
private val isSynced get() = status == Synchronizer.Status.SYNCED
|
||||||
|
|
||||||
override fun inflateBinding(layoutInflater: LayoutInflater): FragmentListTransactionsBinding =
|
override fun inflateBinding(layoutInflater: LayoutInflater): FragmentListTransactionsBinding =
|
||||||
FragmentListTransactionsBinding.inflate(layoutInflater)
|
FragmentListTransactionsBinding.inflate(layoutInflater)
|
||||||
|
@ -51,6 +56,7 @@ class ListTransactionsFragment : BaseDemoFragment<FragmentListTransactionsBindin
|
||||||
LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false)
|
LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false)
|
||||||
adapter = TransactionAdapter()
|
adapter = TransactionAdapter()
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
|
address = synchronizer.getAddress()
|
||||||
synchronizer.receivedTransactions.collect { onTransactionsUpdated(it) }
|
synchronizer.receivedTransactions.collect { onTransactionsUpdated(it) }
|
||||||
}
|
}
|
||||||
binding.recyclerTransactions.adapter = adapter
|
binding.recyclerTransactions.adapter = adapter
|
||||||
|
@ -64,20 +70,22 @@ class ListTransactionsFragment : BaseDemoFragment<FragmentListTransactionsBindin
|
||||||
|
|
||||||
private fun monitorStatus() {
|
private fun monitorStatus() {
|
||||||
synchronizer.status.collectWith(lifecycleScope, ::onStatus)
|
synchronizer.status.collectWith(lifecycleScope, ::onStatus)
|
||||||
|
synchronizer.processorInfo.collectWith(lifecycleScope, ::onProcessorInfoUpdated)
|
||||||
synchronizer.progress.collectWith(lifecycleScope, ::onProgress)
|
synchronizer.progress.collectWith(lifecycleScope, ::onProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onProcessorInfoUpdated(info: CompactBlockProcessor.ProcessorInfo) {
|
||||||
|
if (info.isScanning) binding.textInfo.text = "Scanning blocks...${info.scanProgress}%"
|
||||||
|
}
|
||||||
|
|
||||||
private fun onProgress(i: Int) {
|
private fun onProgress(i: Int) {
|
||||||
val message = when (i) {
|
if (i < 100) binding.textInfo.text = "Downloading blocks...$i%"
|
||||||
100 -> "Scanning blocks..."
|
|
||||||
else -> "Downloading blocks...$i%"
|
|
||||||
}
|
|
||||||
binding.textInfo.text = message
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onStatus(status: Synchronizer.Status) {
|
private fun onStatus(status: Synchronizer.Status) {
|
||||||
|
this.status = status
|
||||||
binding.textStatus.text = "Status: $status"
|
binding.textStatus.text = "Status: $status"
|
||||||
if (status == Synchronizer.Status.SYNCED) onSyncComplete()
|
if (isSynced) onSyncComplete()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onSyncComplete() {
|
private fun onSyncComplete() {
|
||||||
|
@ -87,5 +95,24 @@ class ListTransactionsFragment : BaseDemoFragment<FragmentListTransactionsBindin
|
||||||
private fun onTransactionsUpdated(transactions: PagedList<ConfirmedTransaction>) {
|
private fun onTransactionsUpdated(transactions: PagedList<ConfirmedTransaction>) {
|
||||||
twig("got a new paged list of transactions")
|
twig("got a new paged list of transactions")
|
||||||
adapter.submitList(transactions)
|
adapter.submitList(transactions)
|
||||||
|
|
||||||
|
// show message when there are no transactions
|
||||||
|
if (isSynced) {
|
||||||
|
binding.textInfo.apply {
|
||||||
|
if (transactions.isEmpty()) {
|
||||||
|
visibility = View.VISIBLE
|
||||||
|
text =
|
||||||
|
"No transactions found. Try to either change the seed words in the" +
|
||||||
|
" DemoConfig.kt file or send funds to this address (tap the FAB to copy it):\n\n $address"
|
||||||
|
} else {
|
||||||
|
visibility = View.INVISIBLE
|
||||||
|
text = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActionButtonClicked() {
|
||||||
|
if (::address.isInitialized) copyToClipboard(address)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@ import cash.z.wallet.sdk.ext.*
|
||||||
class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
|
class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
|
||||||
private val config = App.instance.defaultConfig
|
private val config = App.instance.defaultConfig
|
||||||
private val initializer = Initializer(App.instance, host = config.host, port = config.port)
|
private val initializer = Initializer(App.instance, host = config.host, port = config.port)
|
||||||
private val birthday = config.newWalletBirthday()
|
private val birthday = config.loadBirthday()
|
||||||
|
|
||||||
private lateinit var synchronizer: Synchronizer
|
private lateinit var synchronizer: Synchronizer
|
||||||
private lateinit var keyManager: SampleStorageBridge
|
private lateinit var keyManager: SampleStorageBridge
|
||||||
|
@ -31,7 +31,7 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
|
||||||
// Observable properties (done without livedata or flows for simplicity)
|
// Observable properties (done without livedata or flows for simplicity)
|
||||||
//
|
//
|
||||||
|
|
||||||
private var availableBalance = -1L
|
private var balance = CompactBlockProcessor.WalletBalance()
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
onUpdateSendButton()
|
onUpdateSendButton()
|
||||||
|
@ -98,40 +98,47 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
|
||||||
private fun monitorChanges() {
|
private fun monitorChanges() {
|
||||||
synchronizer.status.collectWith(lifecycleScope, ::onStatus)
|
synchronizer.status.collectWith(lifecycleScope, ::onStatus)
|
||||||
synchronizer.progress.collectWith(lifecycleScope, ::onProgress)
|
synchronizer.progress.collectWith(lifecycleScope, ::onProgress)
|
||||||
|
synchronizer.processorInfo.collectWith(lifecycleScope, ::onProcessorInfoUpdated)
|
||||||
synchronizer.balances.collectWith(lifecycleScope, ::onBalance)
|
synchronizer.balances.collectWith(lifecycleScope, ::onBalance)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onStatus(status: Synchronizer.Status) {
|
private fun onStatus(status: Synchronizer.Status) {
|
||||||
binding.textStatus.text = "Status: $status"
|
binding.textStatus.text = "Status: $status"
|
||||||
if (status != Synchronizer.Status.SYNCED) {
|
isSyncing = status != Synchronizer.Status.SYNCED
|
||||||
isSyncing = true
|
if (status == Synchronizer.Status.SCANNING) {
|
||||||
binding.textBalance.text = "Calculating balance..."
|
binding.textBalance.text = "Calculating balance..."
|
||||||
} else {
|
} else {
|
||||||
isSyncing = false
|
if (!isSyncing) onBalance(balance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onProgress(i: Int) {
|
private fun onProgress(i: Int) {
|
||||||
val message = when (i) {
|
if (i < 100) {
|
||||||
100 -> "Scanning blocks..."
|
binding.textStatus.text = "Downloading blocks...$i%"
|
||||||
else -> "Downloading blocks...$i%"
|
binding.textBalance.visibility = View.INVISIBLE
|
||||||
|
} else {
|
||||||
|
binding.textBalance.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
binding.textStatus.text = message
|
}
|
||||||
binding.textBalance.text = ""
|
|
||||||
|
private fun onProcessorInfoUpdated(info: CompactBlockProcessor.ProcessorInfo) {
|
||||||
|
if (info.isScanning) binding.textStatus.text = "Scanning blocks...${info.scanProgress}%"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onBalance(balance: CompactBlockProcessor.WalletBalance) {
|
private fun onBalance(balance: CompactBlockProcessor.WalletBalance) {
|
||||||
availableBalance = balance.availableZatoshi
|
this.balance = balance
|
||||||
binding.textBalance.text = """
|
if (!isSyncing) {
|
||||||
Available balance: ${balance.availableZatoshi.convertZatoshiToZecString()}
|
binding.textBalance.text = """
|
||||||
Total balance: ${balance.totalZatoshi.convertZatoshiToZecString()}
|
Available balance: ${balance.availableZatoshi.convertZatoshiToZecString(12)}
|
||||||
""".trimIndent()
|
Total balance: ${balance.totalZatoshi.convertZatoshiToZecString(12)}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onSend(unused: View) {
|
private fun onSend(unused: View) {
|
||||||
isSending = true
|
isSending = true
|
||||||
val amount = amountInput.text.toString().toDouble().convertZecToZatoshi()
|
val amount = amountInput.text.toString().toDouble().convertZecToZatoshi()
|
||||||
val toAddress = addressInput.text.toString()
|
val toAddress = addressInput.text.toString().trim()
|
||||||
synchronizer.sendToAddress(
|
synchronizer.sendToAddress(
|
||||||
keyManager.key,
|
keyManager.key,
|
||||||
amount,
|
amount,
|
||||||
|
@ -169,7 +176,7 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
|
||||||
text = "⌛ syncing"
|
text = "⌛ syncing"
|
||||||
isEnabled = false
|
isEnabled = false
|
||||||
}
|
}
|
||||||
availableBalance <= 0 -> isEnabled = false
|
balance.availableZatoshi <= 0 -> isEnabled = false
|
||||||
else -> {
|
else -> {
|
||||||
text = "send"
|
text = "send"
|
||||||
isEnabled = true
|
isEnabled = true
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
package cash.z.wallet.sdk.demoapp.util
|
|
||||||
|
|
||||||
import cash.z.wallet.sdk.Initializer
|
|
||||||
import cash.z.wallet.sdk.demoapp.App
|
|
||||||
|
|
||||||
data class DemoConfig(
|
|
||||||
val host: String = "34.68.177.238",//"192.168.1.134",//
|
|
||||||
val port: Int = 9067,
|
|
||||||
val birthdayHeight: Int = 620_000,//523_240,
|
|
||||||
val network: ZcashNetwork = ZcashNetwork.TEST_NET,
|
|
||||||
val seed: ByteArray = "testreferencealice____placeholder".toByteArray(),
|
|
||||||
val toAddress: String = "ztestsapling1fg82ar8y8whjfd52l0xcq0w3n7nn7cask2scp9rp27njeurr72ychvud57s9tu90fdqgwdt07lg",
|
|
||||||
val sendAmount: Double = 0.0024
|
|
||||||
) {
|
|
||||||
fun newWalletBirthday() = Initializer.DefaultBirthdayStore.loadBirthdayFromAssets(App.instance)
|
|
||||||
fun loadBirthday() = Initializer.DefaultBirthdayStore.loadBirthdayFromAssets(App.instance, birthdayHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class ZcashNetwork { MAIN_NET, TEST_NET }
|
|
|
@ -0,0 +1,82 @@
|
||||||
|
package cash.z.wallet.sdk.demoapp.util
|
||||||
|
|
||||||
|
import cash.z.android.plugin.MnemonicPlugin
|
||||||
|
import io.github.novacrypto.bip39.MnemonicGenerator
|
||||||
|
import io.github.novacrypto.bip39.SeedCalculator
|
||||||
|
import io.github.novacrypto.bip39.Words
|
||||||
|
import io.github.novacrypto.bip39.wordlists.English
|
||||||
|
import java.security.SecureRandom
|
||||||
|
|
||||||
|
class SimpleMnemonics : MnemonicPlugin {
|
||||||
|
|
||||||
|
override fun nextEntropy(): ByteArray {
|
||||||
|
return ByteArray(Words.TWENTY_FOUR.byteLength()).apply {
|
||||||
|
SecureRandom().nextBytes(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun nextMnemonic(): CharArray {
|
||||||
|
return nextMnemonic(nextEntropy())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun nextMnemonic(entropy: ByteArray): CharArray {
|
||||||
|
return StringBuilder().let { builder ->
|
||||||
|
MnemonicGenerator(English.INSTANCE).createMnemonic(entropy) { c ->
|
||||||
|
builder.append(c)
|
||||||
|
}
|
||||||
|
builder.toString().toCharArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun nextMnemonicList(): List<CharArray> {
|
||||||
|
return nextMnemonicList(nextEntropy())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun nextMnemonicList(entropy: ByteArray): List<CharArray> {
|
||||||
|
return WordListBuilder().let { builder ->
|
||||||
|
MnemonicGenerator(English.INSTANCE).createMnemonic(entropy) { c ->
|
||||||
|
builder.append(c)
|
||||||
|
}
|
||||||
|
builder.wordList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toSeed(mnemonic: CharArray): ByteArray {
|
||||||
|
return SeedCalculator().calculateSeed(String(mnemonic), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toWordList(mnemonic: CharArray): List<CharArray> {
|
||||||
|
val wordList = mutableListOf<CharArray>()
|
||||||
|
var cursor = 0
|
||||||
|
repeat(mnemonic.size) { i ->
|
||||||
|
val isSpace = mnemonic[i] == ' '
|
||||||
|
if (isSpace || i == (mnemonic.size - 1)) {
|
||||||
|
val wordSize = i - cursor + if (isSpace) 0 else 1
|
||||||
|
wordList.add(CharArray(wordSize).apply {
|
||||||
|
repeat(wordSize) {
|
||||||
|
this[it] = mnemonic[cursor + it]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
cursor = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wordList
|
||||||
|
}
|
||||||
|
|
||||||
|
class WordListBuilder {
|
||||||
|
val wordList = mutableListOf<CharArray>()
|
||||||
|
fun append(c: CharSequence) {
|
||||||
|
if (c[0] != English.INSTANCE.space) addWord(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addWord(c: CharSequence) {
|
||||||
|
c.length.let { size ->
|
||||||
|
val word = CharArray(size)
|
||||||
|
repeat(size) {
|
||||||
|
word[it] = c[it]
|
||||||
|
}
|
||||||
|
wordList.add(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,9 +2,16 @@
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent">
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
|
||||||
|
<androidx.constraintlayout.widget.Guideline
|
||||||
|
android:id="@+id/guideline_content_end"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
app:layout_constraintGuide_percent="0.88" />
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
android:id="@+id/text_layout_start_height"
|
android:id="@+id/text_layout_start_height"
|
||||||
|
@ -15,7 +22,7 @@
|
||||||
app:layout_constraintHorizontal_chainStyle="packed"
|
app:layout_constraintHorizontal_chainStyle="packed"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintVertical_bias="0.2"/>
|
app:layout_constraintVertical_bias="0.2" />
|
||||||
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
@ -39,27 +46,27 @@
|
||||||
android:textSize="20sp"
|
android:textSize="20sp"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent"/>
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_info"
|
android:id="@+id/text_info"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="16dp"
|
||||||
android:text="loading blocks..."
|
android:text="loading blocks..."
|
||||||
android:textSize="20sp"
|
android:textSize="20sp"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/text_status"/>
|
app:layout_constraintTop_toBottomOf="@id/text_status" />
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/recycler_transactions"
|
android:id="@+id/recycler_transactions"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="200dp"
|
android:layout_height="0dp"
|
||||||
android:layout_marginStart="16dp"
|
android:layout_margin="16dp"
|
||||||
android:layout_marginEnd="16dp"
|
|
||||||
android:layout_marginTop="32dp"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/text_info"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/text_status"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/guideline_content_end"
|
||||||
tools:itemCount="15"
|
tools:itemCount="15"
|
||||||
tools:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
tools:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||||
tools:listitem="@layout/item_transaction"
|
tools:listitem="@layout/item_transaction"
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
<string name="app_name">Demo App</string>
|
<string name="app_name">Demo App</string>
|
||||||
<string name="navigation_drawer_open">Open navigation drawer</string>
|
<string name="navigation_drawer_open">Open navigation drawer</string>
|
||||||
<string name="navigation_drawer_close">Close navigation drawer</string>
|
<string name="navigation_drawer_close">Close navigation drawer</string>
|
||||||
<string name="nav_header_title">Android SDK Demo</string>
|
|
||||||
<string name="nav_header_subtitle">v1.0.0-alpha02</string>
|
<string name="nav_header_subtitle">v1.0.0-alpha02</string>
|
||||||
<string name="nav_header_desc">Navigation header</string>
|
<string name="nav_header_desc">Navigation header</string>
|
||||||
<string name="action_settings">Settings</string>
|
<string name="action_settings">Settings</string>
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
package cash.z.wallet.sdk.demoapp
|
||||||
|
|
||||||
|
import cash.z.wallet.sdk.Initializer
|
||||||
|
import cash.z.wallet.sdk.demoapp.util.SimpleMnemonics
|
||||||
|
|
||||||
|
data class DemoConfig(
|
||||||
|
val host: String = "lightwalletd.z.cash",
|
||||||
|
val port: Int = 9067,
|
||||||
|
val birthdayHeight: Int = 735_000,
|
||||||
|
val sendAmount: Double = 0.0018,
|
||||||
|
|
||||||
|
// corresponds to address: zs15tzaulx5weua5c7l47l4pku2pw9fzwvvnsp4y80jdpul0y3nwn5zp7tmkcclqaca3mdjqjkl7hx
|
||||||
|
val seedWords: String = "wish puppy smile loan doll curve hole maze file ginger hair nose key relax knife witness cannon grab despair throw review deal slush frame",
|
||||||
|
|
||||||
|
// corresponds to seed: urban kind wise collect social marble riot primary craft lucky head cause syrup odor artist decorate rhythm phone style benefit portion bus truck top
|
||||||
|
val toAddress: String = "zs1lcdmue7rewgvzh3jd09sfvwq3sumu6hkhpk53q94kcneuffjkdg9e3tyxrugkmpza5c3c5e6eqh"
|
||||||
|
) {
|
||||||
|
val seed: ByteArray get() = SimpleMnemonics().toSeed(seedWords.toCharArray())
|
||||||
|
|
||||||
|
fun newWalletBirthday() = Initializer.DefaultBirthdayStore.loadBirthdayFromAssets(App.instance)
|
||||||
|
fun loadBirthday(height: Int = birthdayHeight) = Initializer.DefaultBirthdayStore.loadBirthdayFromAssets(App.instance, height)
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Demo App - Mainnet</string>
|
||||||
|
<string name="nav_header_title">Android SDK Demo : MAINNET</string>
|
||||||
|
</resources>
|
|
@ -20,12 +20,9 @@ buildscript {
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
// mavenLocal()
|
|
||||||
// flatDir {
|
|
||||||
// dirs 'libs'
|
|
||||||
// }
|
|
||||||
google()
|
google()
|
||||||
jcenter()
|
jcenter()
|
||||||
|
maven { url 'https://jitpack.io' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -402,10 +402,48 @@ class CompactBlockProcessor(
|
||||||
val lastDownloadRange: IntRange = 0..-1, // empty range
|
val lastDownloadRange: IntRange = 0..-1, // empty range
|
||||||
val lastScanRange: IntRange = 0..-1 // empty range
|
val lastScanRange: IntRange = 0..-1 // empty range
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns false when all values match their defaults.
|
||||||
|
*/
|
||||||
val hasData get() = networkBlockHeight != -1
|
val hasData get() = networkBlockHeight != -1
|
||||||
|| lastScannedHeight != -1
|
|| lastScannedHeight != -1
|
||||||
|| lastDownloadedHeight != -1
|
|| lastDownloadedHeight != -1
|
||||||
|| lastDownloadRange != 0..-1
|
|| lastDownloadRange != 0..-1
|
||||||
|| lastScanRange != 0..-1
|
|| lastScanRange != 0..-1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when there are more than zero blocks remaining to download.
|
||||||
|
*/
|
||||||
|
val isDownloading: Boolean get() = !lastDownloadRange.isEmpty()
|
||||||
|
&& lastDownloadedHeight < lastDownloadRange.last
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when downloading has completed and there are more than zero blocks remaining
|
||||||
|
* to be scanned.
|
||||||
|
*/
|
||||||
|
val isScanning: Boolean get() = !isDownloading
|
||||||
|
&& !lastScanRange.isEmpty()
|
||||||
|
&& lastScannedHeight < lastScanRange.last
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of scan progress from 0 to 100.
|
||||||
|
*/
|
||||||
|
val scanProgress get() = when {
|
||||||
|
lastScannedHeight <= -1 -> 0
|
||||||
|
lastScanRange.isEmpty() -> 100
|
||||||
|
lastScannedHeight >= lastScanRange.last -> 100
|
||||||
|
else -> {
|
||||||
|
// when lastScannedHeight == lastScanRange.first, we have scanned one block, thus the offsets
|
||||||
|
val blocksScanned = (lastScannedHeight - lastScanRange.first + 1).coerceAtLeast(0)
|
||||||
|
// we scan the range inclusively so 100..100 is one block to scan, thus the offset
|
||||||
|
val numberOfBlocks = lastScanRange.last - lastScanRange.first + 1
|
||||||
|
// take the percentage then convert and round
|
||||||
|
((blocksScanned.toFloat() / numberOfBlocks) * 100.0f).let { percent ->
|
||||||
|
percent.coerceAtMost(100.0f).roundToInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue