Create FullNodesAdapter to display a live updated list of the nodes the app is watching and also the one it is connected to. This list is displayed inside a dialog that pops when the user clicks the status icon in the Settings Footer. The dialog also shows the live updated BitShares Block Number

This commit is contained in:
Severiano Jaramillo 2018-12-21 20:55:36 -06:00
parent 504441afa2
commit c4fd79f22c
5 changed files with 264 additions and 1 deletions

View file

@ -0,0 +1,140 @@
package cy.agorise.bitsybitshareswallet.adapters
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.SortedList
import androidx.recyclerview.widget.RecyclerView
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.graphenej.network.FullNode
import java.util.*
/**
* Adapter used to populate the elements of the Bitshares nodes dialog in order to show a list of
* nodes with their latency.
*/
class FullNodesAdapter(private val context: Context) : RecyclerView.Adapter<FullNodesAdapter.ViewHolder>() {
val TAG: String = this.javaClass.name
private val mComparator =
Comparator<FullNode> { a, b -> java.lang.Double.compare(a.latencyValue, b.latencyValue) }
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val tvNodeName: TextView = itemView.findViewById(R.id.tvNodeName)
val ivNodeStatus: ImageView = itemView.findViewById(R.id.ivNodeStatus)
}
private val mSortedList = SortedList(FullNode::class.java, object : SortedList.Callback<FullNode>() {
override fun onInserted(position: Int, count: Int) {
notifyItemRangeInserted(position, count)
}
override fun onRemoved(position: Int, count: Int) {
notifyItemRangeRemoved(position, count)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
notifyItemMoved(fromPosition, toPosition)
}
override fun onChanged(position: Int, count: Int) {
notifyItemRangeChanged(position, count)
}
override fun compare(a: FullNode, b: FullNode): Int {
return mComparator.compare(a, b)
}
override fun areContentsTheSame(oldItem: FullNode, newItem: FullNode): Boolean {
return oldItem.latencyValue == newItem.latencyValue
}
override fun areItemsTheSame(item1: FullNode, item2: FullNode): Boolean {
return item1.url == item2.url
}
})
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FullNodesAdapter.ViewHolder {
val inflater = LayoutInflater.from(context)
val transactionView = inflater.inflate(R.layout.item_node, parent, false)
return ViewHolder(transactionView)
}
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
val fullNode = mSortedList[position]
// Show the green check mark before the node name if that node is the one being used
if (fullNode.isConnected)
viewHolder.ivNodeStatus.setImageResource(R.drawable.ic_connected)
else
viewHolder.ivNodeStatus.setImageDrawable(null)
val latency = fullNode.latencyValue
// Select correct color span according to the latency value
val colorSpan = when {
latency < 400 -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.colorPrimary))
latency < 800 -> ForegroundColorSpan(Color.rgb(255,136,0)) // Holo orange
else -> ForegroundColorSpan(Color.rgb(204,0,0)) // Holo red
}
// Create a string with the latency number colored according to their amount
val ssb = SpannableStringBuilder()
ssb.append(fullNode.url.replace("wss://", ""), StyleSpan(Typeface.BOLD), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
ssb.append(" (")
// 2000 ms is the timeout of the websocket used to calculate the latency, therefore if the
// received latency is greater than such value we can assume the node was not reachable.
val ms = if(latency < 2000) "%.0f ms".format(latency) else "??"
ssb.append(ms, colorSpan, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
ssb.append(")")
viewHolder.tvNodeName.text = ssb
}
/**
* Functions that adds/updates a FullNode to the SortedList
*/
fun add(fullNode: FullNode) {
// Remove the old instance of the FullNode before adding a new one. My understanding is that
// the sorted list should be able to automatically find repeated elements and update them
// instead of adding duplicates but it wasn't working so I opted for manually removing old
// instances of FullNodes before adding the updated ones.
var removed = 0
for (i in 0 until mSortedList.size())
if (mSortedList[i-removed].url == (fullNode.url))
mSortedList.removeItemAt(i-removed++)
mSortedList.add(fullNode)
}
/**
* Function that adds a whole list of nodes to the SortedList. It should only be used at the
* moment of populating the SortedList for the first time.
*/
fun add(fullNodes: List<FullNode>) {
mSortedList.addAll(fullNodes)
}
fun remove(fullNode: FullNode) {
mSortedList.remove(fullNode)
}
override fun getItemCount(): Int {
return mSortedList.size()
}
}

View file

@ -3,6 +3,7 @@ package cy.agorise.bitsybitshareswallet.fragments
import android.content.*
import android.content.Context.CLIPBOARD_SERVICE
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.preference.PreferenceManager
import android.util.Log
@ -13,18 +14,27 @@ import android.widget.Toast
import androidx.fragment.app.Fragment
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.customview.customView
import com.afollestad.materialdialogs.list.customListAdapter
import cy.agorise.bitsybitshareswallet.BuildConfig
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.adapters.FullNodesAdapter
import cy.agorise.bitsybitshareswallet.repositories.AuthorityRepository
import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.bitsybitshareswallet.utils.CryptoUtils
import cy.agorise.graphenej.BrainKey
import cy.agorise.graphenej.api.android.NetworkService
import cy.agorise.graphenej.api.android.RxBus
import cy.agorise.graphenej.api.calls.GetDynamicGlobalProperties
import cy.agorise.graphenej.models.DynamicGlobalProperties
import cy.agorise.graphenej.models.JsonRpcResponse
import cy.agorise.graphenej.network.FullNode
import io.reactivex.Observer
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import kotlinx.android.synthetic.main.fragment_settings.*
import java.text.NumberFormat
class SettingsFragment : Fragment(), ServiceConnection {
private val TAG = this.javaClass.simpleName
@ -37,6 +47,14 @@ class SettingsFragment : Fragment(), ServiceConnection {
/** Flag used to keep track of the NetworkService binding state */
private var mShouldUnbindNetwork: Boolean = false
// Dialog displaying the list of nodes and their latencies
private var mNodesDialog: MaterialDialog? = null
/** Adapter that holds the FullNode list used in the Bitshares nodes modal */
private var nodesAdapter: FullNodesAdapter? = null
private val mHandler = Handler()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
setHasOptionsMenu(true)
@ -54,6 +72,31 @@ class SettingsFragment : Fragment(), ServiceConnection {
tvFooterAppVersion.text = String.format("%s v%s", getString(R.string.app_name), BuildConfig.VERSION_NAME)
ivConnectionStatusIcon.setOnClickListener { v ->
if (mNetworkService != null) {
// PublishSubject used to announce full node latencies updates
val fullNodePublishSubject = mNetworkService!!.nodeLatencyObservable
fullNodePublishSubject?.observeOn(AndroidSchedulers.mainThread())?.subscribe(nodeLatencyObserver)
val fullNodes = mNetworkService!!.nodes
nodesAdapter = FullNodesAdapter(v.context)
nodesAdapter!!.add(fullNodes)
mNodesDialog = MaterialDialog(v.context)
.title(text = getString(R.string.title__bitshares_nodes_dialog, "-------"))
.customListAdapter(nodesAdapter as FullNodesAdapter)
.negativeButton(android.R.string.ok) {
mHandler.removeCallbacks(mRequestDynamicGlobalPropertiesTask)
}
mNodesDialog?.show()
// Registering a recurrent task used to poll for dynamic global properties requests
mHandler.post(mRequestDynamicGlobalPropertiesTask)
}
}
// Connect to the RxBus, which receives events from the NetworkService
mDisposables.add(
RxBus.getBusInstance()
@ -63,8 +106,56 @@ class SettingsFragment : Fragment(), ServiceConnection {
)
}
private fun handleIncomingMessage(message: Any?) {
/**
* Observer used to be notified about node latency measurement updates.
*/
private val nodeLatencyObserver = object : Observer<FullNode> {
override fun onSubscribe(d: Disposable) {
mDisposables.add(d)
}
override fun onNext(fullNode: FullNode) {
if (!fullNode.isRemoved)
nodesAdapter?.add(fullNode)
else
nodesAdapter?.remove(fullNode)
}
override fun onError(e: Throwable) {
Log.e(TAG, "nodeLatencyObserver.onError.Msg: " + e.message)
}
override fun onComplete() {}
}
private fun handleIncomingMessage(message: Any?) {
if (message is JsonRpcResponse<*>) {
if (message.result is DynamicGlobalProperties) {
val dynamicGlobalProperties = message.result as DynamicGlobalProperties
if (mNodesDialog != null && mNodesDialog?.isShowing == true) {
val blockNumber = NumberFormat.getInstance().format(dynamicGlobalProperties.head_block_number)
mNodesDialog?.title(text = getString(R.string.title__bitshares_nodes_dialog, blockNumber))
}
}
}
}
/**
* Task used to obtain frequent updates on the global dynamic properties object
*/
private val mRequestDynamicGlobalPropertiesTask = object : Runnable {
override fun run() {
if (mNetworkService != null) {
if (mNetworkService?.isConnected == true) {
mNetworkService?.sendMessage(GetDynamicGlobalProperties(), GetDynamicGlobalProperties.REQUIRED_API)
} else {
Log.d(TAG, "NetworkService exists but is not connected")
}
} else {
Log.d(TAG, "NetworkService reference is null")
}
mHandler.postDelayed(this, Constants.BLOCK_PERIOD)
}
}
/**

View file

@ -35,6 +35,9 @@ object Constants {
/** Time period to wait to send a request to the NetworkService, and retry in case it is still not connected */
const val NETWORK_SERVICE_RETRY_PERIOD: Long = 5000
/** Bitshares block period */
const val BLOCK_PERIOD: Long = 3000
/** Key used to store the number of operations that the currently selected account had last time we checked */
const val KEY_ACCOUNT_OPERATION_COUNT = "key_account_operation_count"

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:paddingTop="2dp"
android:paddingBottom="2dp">
<ImageView
android:id="@+id/ivNodeStatus"
android:layout_width="16dp"
android:layout_height="16dp"
tools:src="@drawable/ic_connected"/>
<TextView
android:id="@+id/tvNodeName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
tools:text="de.palmpayisthebestappinthefreakingworld.io/ws (123ms)"
android:ellipsize="middle"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Bitsy.Body1"/>
</LinearLayout>

View file

@ -76,6 +76,7 @@
<string name="msg__bugs_or_ideas">Telegram chat: http://t.me/Agorise\nEmail: Agorise@protonmail.ch\nOpen Source:
https://github.com/Agorise
</string>
<string name="title__bitshares_nodes_dialog">Block: %1$s</string>
<string name="text__coming_soon">Coming soon</string>
<string name="title_net_worth">Net Worth</string>
<string name="title_search">Search</string>