From 765ed13a6a2b48155a9e1eebdd5d6feef7735b38 Mon Sep 17 00:00:00 2001 From: Severiano Jaramillo Date: Fri, 23 Aug 2019 14:41:00 -0500 Subject: [PATCH] Add the nodes WS to Bitsy. - Renamed the MerchantsWebservice to BitsyWebservice because it is now going to serve as a source of information of more than just merchants and tellers, but also the nodes the app is gonna connect to. - Created new NodeRepository which will be in charge of accessing and updating the nodes database table with the information obtained from the webservice. - Bitsy is now going to try to obtain the list of nodes it is going to try to connect to from the dabatase and use a hardcoded list as a fallback. The list of nodes in the database is updated regularly as well as their respective latency, so that in future app's startups it can use those latencies to immediately connect to the best node in the last app session. --- app/build.gradle | 4 +- .../activities/ConnectedActivity.kt | 8 +- .../bitsybitshareswallet/models/NodeWS.kt | 8 ++ ...chantsWebservice.kt => BitsyWebservice.kt} | 7 +- .../repositories/MerchantRepository.kt | 8 +- .../repositories/NodeRepository.kt | 123 ++++++++++++++++++ .../repositories/TellerRepository.kt | 8 +- .../utils/BitsyApplication.kt | 60 +++++---- .../bitsybitshareswallet/utils/Constants.kt | 5 +- .../viewmodels/ConnectedActivityViewModel.kt | 14 ++ graphenejlib | 2 +- 11 files changed, 209 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/cy/agorise/bitsybitshareswallet/models/NodeWS.kt rename app/src/main/java/cy/agorise/bitsybitshareswallet/network/{MerchantsWebservice.kt => BitsyWebservice.kt} (77%) diff --git a/app/build.gradle b/app/build.gradle index 740ba3d..e08cdaa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -99,11 +99,11 @@ dependencies { implementation "com.jakewharton.rxbinding3:rxbinding-material:$rx_bindings_version" // Material Components widgets implementation "com.jakewharton.rxbinding3:rxbinding-appcompat:$rx_bindings_version" // AndroidX appcompat widgets // Retrofit & OkHttp + implementation 'com.squareup.okhttp3:okhttp:3.12.2' implementation 'com.squareup.retrofit2:retrofit:2.6.0' implementation 'com.squareup.retrofit2:converter-gson:2.6.0' - implementation 'com.jakewharton.retrofit:retrofit2-rxjava2-adapter:1.0.0' - implementation 'com.squareup.okhttp3:okhttp:3.12.2' implementation 'com.squareup.okhttp3:logging-interceptor:3.12.2' + implementation 'com.jakewharton.retrofit:retrofit2-rxjava2-adapter:1.0.0' //Firebase implementation 'com.google.firebase:firebase-core:17.1.0' implementation 'com.crashlytics.sdk.android:crashlytics:2.10.1' diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt index 2078a07..e4aa55d 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt @@ -35,6 +35,7 @@ import cy.agorise.graphenej.api.android.NetworkService import cy.agorise.graphenej.api.android.RxBus import cy.agorise.graphenej.api.calls.* import cy.agorise.graphenej.models.* +import cy.agorise.graphenej.network.FullNode import io.fabric.sdk.android.Fabric import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers @@ -279,7 +280,7 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { if (latestOpCount == 0L) { Log.d(TAG, "The node returned 0 total_ops for current account and may not have installed the history plugin. " + "\nAsk the NetworkService to remove the node from the list and connect to another one.") - mNetworkService?.removeCurrentNodeAndReconnect() + mNetworkService?.reconnectNode() } else if (storedOpCount == -1L) { // Initial case when the app starts storedOpCount = latestOpCount @@ -478,10 +479,15 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { override fun onPause() { super.onPause() + mNetworkService?.nodeLatencyVerifier?.nodeList?.let { nodes -> + mConnectedActivityViewModel.updateNodeLatencies(nodes as List) + } + // Unbinding from network service if (mShouldUnbindNetwork) { unbindService(this) mShouldUnbindNetwork = false + mNetworkService = null } mHandler.removeCallbacks(mCheckMissingPaymentsTask) mHandler.removeCallbacks(mRequestMissingUserAccountsTask) diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/models/NodeWS.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/models/NodeWS.kt new file mode 100644 index 0000000..575b6f9 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/models/NodeWS.kt @@ -0,0 +1,8 @@ +package cy.agorise.bitsybitshareswallet.models + +/** + * Node object used to deserialize the response from the WebService. + */ +data class NodeWS( + val url: String +) \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/network/MerchantsWebservice.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/network/BitsyWebservice.kt similarity index 77% rename from app/src/main/java/cy/agorise/bitsybitshareswallet/network/MerchantsWebservice.kt rename to app/src/main/java/cy/agorise/bitsybitshareswallet/network/BitsyWebservice.kt index 6c2cc5f..7c778be 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/network/MerchantsWebservice.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/network/BitsyWebservice.kt @@ -2,11 +2,13 @@ package cy.agorise.bitsybitshareswallet.network import cy.agorise.bitsybitshareswallet.database.entities.Merchant import cy.agorise.bitsybitshareswallet.database.entities.Teller +import cy.agorise.bitsybitshareswallet.models.NodeWS import retrofit2.Call +import retrofit2.Response import retrofit2.http.GET import retrofit2.http.Query -interface MerchantsWebservice { +interface BitsyWebservice { @GET("/api/v2/merchants") fun getMerchants(@Query(value = "\$skip") skip: Int, @@ -17,4 +19,7 @@ interface MerchantsWebservice { fun getTellers(@Query(value = "\$skip") skip: Int, @Query(value = "\$limit") limit: Int = 50): Call> + + @GET("/api/v2/nodes") + suspend fun getNodes(): Response> } \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/MerchantRepository.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/MerchantRepository.kt index 99594a6..e29fa0c 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/MerchantRepository.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/MerchantRepository.kt @@ -9,7 +9,7 @@ import cy.agorise.bitsybitshareswallet.database.BitsyDatabase import cy.agorise.bitsybitshareswallet.database.daos.MerchantDao import cy.agorise.bitsybitshareswallet.database.entities.Merchant import cy.agorise.bitsybitshareswallet.network.FeathersResponse -import cy.agorise.bitsybitshareswallet.network.MerchantsWebservice +import cy.agorise.bitsybitshareswallet.network.BitsyWebservice import cy.agorise.bitsybitshareswallet.utils.Constants import io.reactivex.Single import retrofit2.Call @@ -47,12 +47,12 @@ class MerchantRepository internal constructor(val context: Context) : retrofit2. Log.d(TAG, "Updating merchants from webservice") // TODO make sure it works when there are more merchants than those sent back in the first response val retrofit = Retrofit.Builder() - .baseUrl(Constants.MERCHANTS_WEBSERVICE_URL) + .baseUrl(Constants.BITSY_WEBSERVICE_URL) .addConverterFactory(GsonConverterFactory.create()) .build() - val ambassadorService = retrofit.create(MerchantsWebservice::class.java) - val call = ambassadorService.getMerchants(0) + val bitsyWebservice = retrofit.create(BitsyWebservice::class.java) + val call = bitsyWebservice.getMerchants(0) call.enqueue(this) } } diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/NodeRepository.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/NodeRepository.kt index 82428ef..361ed50 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/NodeRepository.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/NodeRepository.kt @@ -1,7 +1,130 @@ package cy.agorise.bitsybitshareswallet.repositories +import android.os.AsyncTask +import android.util.Log +import com.crashlytics.android.Crashlytics import cy.agorise.bitsybitshareswallet.database.daos.NodeDao +import cy.agorise.bitsybitshareswallet.database.entities.Node +import cy.agorise.bitsybitshareswallet.network.BitsyWebservice +import cy.agorise.bitsybitshareswallet.network.ServiceGenerator +import cy.agorise.bitsybitshareswallet.utils.Constants +import cy.agorise.graphenej.network.FullNode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class NodeRepository(private val nodeDao: NodeDao) { + companion object { + private const val TAG = "NodeRepository" + + // Minimum number of nodes required to update the nodes db table. + private const val MIN_NODES_SIZE = 3 + + // List of BitShares nodes the app will try to connect to + var BITSHARES_NODE_URLS = arrayOf( + // PP private nodes + "wss://nl.palmpay.io/ws", + + // Other public nodes + "wss://btsws.roelandp.nl/ws", + "wss://api.bts.mobi/ws", + "wss://kimziv.com/ws", + "wss://api.bts.ai") + } + + private val mBitsyWebservice: BitsyWebservice + + init { + val sg = ServiceGenerator(Constants.BITSY_WEBSERVICE_URL) + mBitsyWebservice = sg.getService(BitsyWebservice::class.java) + } + + /** + * Returns a Pair of items: + * First. A list of comma separated node urls in form of a string. The node urls come from the + * database if the nodes table is already populated, else a default list is used. + * Second. A Boolean that specifies if the app should try to autoConnect immediately, or wait + * for other event to launch the connect method. + */ + suspend fun getFormattedNodes(): Pair { + val nodes = nodeDao.getSortedNodes() + + // TODO verify if this is the best way to fire and forget launch a coroutine inside another coroutine + // Launches a job to refresh the list of nodes into the database, without blocking the + // execution of this function, so that the formatted nodes can be returned immediately + // without waiting until the nodes have been updated in the database. + CoroutineScope(Dispatchers.Default).launch { + refreshNodes(nodes) + } + + return if (nodes.size < MIN_NODES_SIZE) { + // If the nodes db table is empty or very small, it could mean that the nodes have not + // still been updated from the webservice, thus returning a default list of nodes as a fallback. + // False is returned since we want to verify the node latencies before choosing the best + // one and trying to connect to it. + Pair(getDefaultFormattedNodes(), false) + } else { + // Use the list of nodes stored in the database. True is returned since the list of nodes + // is already ordered by latency, and we don't need to wait to obtain the latency + // readings, thus the app can immediately try to connect to the first node in the list. + Pair(getDBFormattedNodes(nodes), true) + } + } + + /** + * Verifies if the nodes information should be updated and if true, fetches the nodes + * information from the webservice and updates the database + */ + private suspend fun refreshNodes(nodes: List) { + val now = System.currentTimeMillis() / 1000 + val lastUpdate: Long = if (nodes.size < MIN_NODES_SIZE) { + 0 + } else { + nodes[0].lastUpdate + } + val updatePeriod = Constants.NODES_UPDATE_PERIOD + // Verify if nodes list should be updated + if (now - updatePeriod > lastUpdate) { + val response = mBitsyWebservice.getNodes() + try { + // Update the list of nodes only if we got at least MIN_NODES_SIZE nodes + if (response.isSuccessful && (response.body()?.size ?: 0) >= MIN_NODES_SIZE) { + val nodesWS = response.body() ?: return + + val nodesDB = nodesWS.map { + Node(url = it.url, lastUpdate = now) + } + + Log.d(TAG, "Updating the list of nodes.") + nodeDao.updateNodes(nodesDB, now) + } + } catch (e: Exception) { + // Generic exception handling + Crashlytics.logException(e) + } + } + } + + private fun getDefaultFormattedNodes(): String { + return BITSHARES_NODE_URLS.joinToString(separator = ",") + } + + private fun getDBFormattedNodes(nodes: List): String { + return nodes.joinToString(separator = ",") { it.url } + } + + /** + * Function that will receive an up-to-date list of FullNode instances and persist it on + * the database. + * + * @param nodes List of nodes with fresh latency measurements. + */ + fun updateNodeLatencies(nodes: List) { + AsyncTask.execute { + nodes.forEach { + nodeDao.updateLatency(it.latencyValue.toLong(), it.url) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/TellerRepository.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/TellerRepository.kt index 5c46e2d..6890b2c 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/TellerRepository.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/TellerRepository.kt @@ -9,7 +9,7 @@ import cy.agorise.bitsybitshareswallet.database.BitsyDatabase import cy.agorise.bitsybitshareswallet.database.daos.TellerDao import cy.agorise.bitsybitshareswallet.database.entities.Teller import cy.agorise.bitsybitshareswallet.network.FeathersResponse -import cy.agorise.bitsybitshareswallet.network.MerchantsWebservice +import cy.agorise.bitsybitshareswallet.network.BitsyWebservice import cy.agorise.bitsybitshareswallet.utils.Constants import io.reactivex.Single import retrofit2.Call @@ -47,12 +47,12 @@ class TellerRepository internal constructor(val context: Context) : retrofit2.Ca Log.d(TAG, "Updating tellers from webservice") // TODO make sure it works when there are more tellers than those sent back in the first response val retrofit = Retrofit.Builder() - .baseUrl(Constants.MERCHANTS_WEBSERVICE_URL) + .baseUrl(Constants.BITSY_WEBSERVICE_URL) .addConverterFactory(GsonConverterFactory.create()) .build() - val ambassadorService = retrofit.create(MerchantsWebservice::class.java) - val call = ambassadorService.getTellers(0) + val bitsyWebservice = retrofit.create(BitsyWebservice::class.java) + val call = bitsyWebservice.getTellers(0) call.enqueue(this) } } diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/BitsyApplication.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/BitsyApplication.kt index f0ce804..6bf7244 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/BitsyApplication.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/BitsyApplication.kt @@ -2,29 +2,30 @@ package cy.agorise.bitsybitshareswallet.utils import android.app.Application import com.crashlytics.android.Crashlytics +import cy.agorise.bitsybitshareswallet.database.BitsyDatabase +import cy.agorise.bitsybitshareswallet.repositories.NodeRepository import cy.agorise.graphenej.api.ApiAccess import cy.agorise.graphenej.api.android.NetworkServiceManager import io.reactivex.plugins.RxJavaPlugins +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch -@Suppress("unused") class BitsyApplication : Application() { - companion object { - private val BITSHARES_NODE_URLS = arrayOf( - // PP private nodes - "wss://nl.palmpay.io/ws", + /** + * Coroutine Job used to create appScope and safely cancel all coroutines launched using it. + */ + private val applicationJob = Job() - // Other public nodes - "wss://kc-us-dex.xeldal.com/ws", // missouri, usa -// "wss://bitshares.nu/ws", // Stockholm, Sweden - "wss://bitshares.openledger.info/ws" // Openledger node -// "wss://dallas.bitshares.apasia.tech/ws", // Dallas, USA -// "wss://atlanta.bitshares.apasia.tech/ws", // Atlanta, USA -// "wss://dex.rnglab.org", // Amsterdam, Netherlands -// "wss://citadel.li/node" - ) - } + /** + * Application level scope used to launch coroutines not tied to ViewModels or Activities/Fragments. + */ + lateinit var appScope: CoroutineScope + + private lateinit var mNodeRepository: NodeRepository override fun onCreate() { super.onCreate() @@ -33,15 +34,28 @@ class BitsyApplication : Application() { // exception to Crashlytics so that we can fix the issues RxJavaPlugins.setErrorHandler { throwable -> Crashlytics.logException(throwable)} + appScope = CoroutineScope(Dispatchers.Main + applicationJob) + + val nodeDao = BitsyDatabase.getDatabase(applicationContext)!!.nodeDao() + + mNodeRepository = NodeRepository(nodeDao) + + appScope.launch { + startNetworkServiceConnection() + } + } + + private suspend fun startNetworkServiceConnection() { // Specifying some important information regarding the connection, such as the // credentials and the requested API accesses val requestedApis = ApiAccess.API_DATABASE or ApiAccess.API_HISTORY or ApiAccess.API_NETWORK_BROADCAST + val (nodes, autoConnect) = mNodeRepository.getFormattedNodes() val networkManager = NetworkServiceManager.Builder() .setUserName("") .setPassword("") .setRequestedApis(requestedApis) - .setCustomNodeUrls(setupNodes()) - .setAutoConnect(true) + .setCustomNodeUrls(nodes) + .setAutoConnect(autoConnect) .setNodeLatencyVerification(true) .build(this) @@ -52,12 +66,10 @@ class BitsyApplication : Application() { registerActivityLifecycleCallbacks(networkManager) } - private fun setupNodes(): String { - val stringBuilder = StringBuilder() - for (url in BITSHARES_NODE_URLS) { - stringBuilder.append(url).append(",") - } - stringBuilder.replace(stringBuilder.length - 1, stringBuilder.length, "") - return stringBuilder.toString() + override fun onTerminate() { + super.onTerminate() + + // Cancel the job which also cancels the scopes created using it, i.e. appScope + applicationJob.cancel() } } \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Constants.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Constants.kt index 55d87ea..bf55faf 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Constants.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Constants.kt @@ -108,7 +108,7 @@ object Constants { /** Key used to store the night mode setting into the shared preferences */ const val KEY_NIGHT_MODE_ACTIVATED = "key_night_mode_activated" - const val MERCHANTS_WEBSERVICE_URL = "https://websvc.palmpay.io/" + const val BITSY_WEBSERVICE_URL = "https://websvc.palmpay.io/" /** Key used to store the last time in millis that the merchants info was refreshed */ const val KEY_MERCHANTS_LAST_UPDATE = "key_merchants_last_update" @@ -125,6 +125,9 @@ object Constants { /** Constant used to check if the current connected node is out of sync */ const val CHECK_NODE_OUT_OF_SYNC = 10 // 10 seconds + /** Minimum time period in seconds between BitShares nodes list updates */ + const val NODES_UPDATE_PERIOD = (60 * 60).toLong() // 1 hour + /////////////////////// Crashlytics custom keys /////////////////////// diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/ConnectedActivityViewModel.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/ConnectedActivityViewModel.kt index 6d3e601..d4eb1b3 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/ConnectedActivityViewModel.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/ConnectedActivityViewModel.kt @@ -2,15 +2,29 @@ package cy.agorise.bitsybitshareswallet.viewmodels import android.app.Application import androidx.lifecycle.AndroidViewModel +import cy.agorise.bitsybitshareswallet.database.BitsyDatabase +import cy.agorise.bitsybitshareswallet.repositories.NodeRepository import cy.agorise.bitsybitshareswallet.repositories.TransferRepository +import cy.agorise.graphenej.network.FullNode class ConnectedActivityViewModel(application: Application) : AndroidViewModel(application) { private var mTransfersRepository = TransferRepository(application) + private val mNodeRepository: NodeRepository + + init { + val nodeDao = BitsyDatabase.getDatabase(application)!!.nodeDao() + mNodeRepository = NodeRepository(nodeDao) + } + fun observeMissingEquivalentValuesIn(symbol: String) { mTransfersRepository.observeMissingEquivalentValuesIn(symbol) } + fun updateNodeLatencies(nodes: List) { + mNodeRepository.updateNodeLatencies(nodes) + } + override fun onCleared() { super.onCleared() mTransfersRepository.onCleared() diff --git a/graphenejlib b/graphenejlib index 954cf3e..606f7c1 160000 --- a/graphenejlib +++ b/graphenejlib @@ -1 +1 @@ -Subproject commit 954cf3e16d77038feff711ed3b93818470e36b15 +Subproject commit 606f7c183e170285d45f6977faba435966dac4e9