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:
parent
504441afa2
commit
c4fd79f22c
5 changed files with 264 additions and 1 deletions
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ package cy.agorise.bitsybitshareswallet.fragments
|
||||||
import android.content.*
|
import android.content.*
|
||||||
import android.content.Context.CLIPBOARD_SERVICE
|
import android.content.Context.CLIPBOARD_SERVICE
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.preference.PreferenceManager
|
import android.preference.PreferenceManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
@ -13,18 +14,27 @@ import android.widget.Toast
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.afollestad.materialdialogs.MaterialDialog
|
import com.afollestad.materialdialogs.MaterialDialog
|
||||||
import com.afollestad.materialdialogs.customview.customView
|
import com.afollestad.materialdialogs.customview.customView
|
||||||
|
import com.afollestad.materialdialogs.list.customListAdapter
|
||||||
import cy.agorise.bitsybitshareswallet.BuildConfig
|
import cy.agorise.bitsybitshareswallet.BuildConfig
|
||||||
import cy.agorise.bitsybitshareswallet.R
|
import cy.agorise.bitsybitshareswallet.R
|
||||||
|
import cy.agorise.bitsybitshareswallet.adapters.FullNodesAdapter
|
||||||
import cy.agorise.bitsybitshareswallet.repositories.AuthorityRepository
|
import cy.agorise.bitsybitshareswallet.repositories.AuthorityRepository
|
||||||
import cy.agorise.bitsybitshareswallet.utils.Constants
|
import cy.agorise.bitsybitshareswallet.utils.Constants
|
||||||
import cy.agorise.bitsybitshareswallet.utils.CryptoUtils
|
import cy.agorise.bitsybitshareswallet.utils.CryptoUtils
|
||||||
import cy.agorise.graphenej.BrainKey
|
import cy.agorise.graphenej.BrainKey
|
||||||
import cy.agorise.graphenej.api.android.NetworkService
|
import cy.agorise.graphenej.api.android.NetworkService
|
||||||
import cy.agorise.graphenej.api.android.RxBus
|
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.android.schedulers.AndroidSchedulers
|
||||||
import io.reactivex.disposables.CompositeDisposable
|
import io.reactivex.disposables.CompositeDisposable
|
||||||
|
import io.reactivex.disposables.Disposable
|
||||||
import io.reactivex.schedulers.Schedulers
|
import io.reactivex.schedulers.Schedulers
|
||||||
import kotlinx.android.synthetic.main.fragment_settings.*
|
import kotlinx.android.synthetic.main.fragment_settings.*
|
||||||
|
import java.text.NumberFormat
|
||||||
|
|
||||||
class SettingsFragment : Fragment(), ServiceConnection {
|
class SettingsFragment : Fragment(), ServiceConnection {
|
||||||
private val TAG = this.javaClass.simpleName
|
private val TAG = this.javaClass.simpleName
|
||||||
|
@ -37,6 +47,14 @@ class SettingsFragment : Fragment(), ServiceConnection {
|
||||||
/** Flag used to keep track of the NetworkService binding state */
|
/** Flag used to keep track of the NetworkService binding state */
|
||||||
private var mShouldUnbindNetwork: Boolean = false
|
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? {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||||
setHasOptionsMenu(true)
|
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)
|
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
|
// Connect to the RxBus, which receives events from the NetworkService
|
||||||
mDisposables.add(
|
mDisposables.add(
|
||||||
RxBus.getBusInstance()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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 */
|
/** 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
|
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 */
|
/** 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"
|
const val KEY_ACCOUNT_OPERATION_COUNT = "key_account_operation_count"
|
||||||
|
|
||||||
|
|
28
app/src/main/res/layout/item_node.xml
Normal file
28
app/src/main/res/layout/item_node.xml
Normal 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>
|
|
@ -76,6 +76,7 @@
|
||||||
<string name="msg__bugs_or_ideas">Telegram chat: http://t.me/Agorise\nEmail: Agorise@protonmail.ch\nOpen Source:
|
<string name="msg__bugs_or_ideas">Telegram chat: http://t.me/Agorise\nEmail: Agorise@protonmail.ch\nOpen Source:
|
||||||
https://github.com/Agorise
|
https://github.com/Agorise
|
||||||
</string>
|
</string>
|
||||||
|
<string name="title__bitshares_nodes_dialog">Block: %1$s</string>
|
||||||
<string name="text__coming_soon">Coming soon</string>
|
<string name="text__coming_soon">Coming soon</string>
|
||||||
<string name="title_net_worth">Net Worth</string>
|
<string name="title_net_worth">Net Worth</string>
|
||||||
<string name="title_search">Search</string>
|
<string name="title_search">Search</string>
|
||||||
|
|
Loading…
Reference in a new issue