diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/FullNodesAdapter.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/FullNodesAdapter.kt new file mode 100644 index 0000000..b477898 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/FullNodesAdapter.kt @@ -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() { + val TAG: String = this.javaClass.name + + private val mComparator = + Comparator { 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() { + 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) { + mSortedList.addAll(fullNodes) + } + + fun remove(fullNode: FullNode) { + mSortedList.remove(fullNode) + } + + override fun getItemCount(): Int { + return mSortedList.size() + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/SettingsFragment.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/SettingsFragment.kt index ad6e8fa..eb7f895 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/SettingsFragment.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/SettingsFragment.kt @@ -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 { + 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) + } } /** 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 2dd9d16..f404a82 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Constants.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Constants.kt @@ -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" diff --git a/app/src/main/res/layout/item_node.xml b/app/src/main/res/layout/item_node.xml new file mode 100644 index 0000000..bb86a7c --- /dev/null +++ b/app/src/main/res/layout/item_node.xml @@ -0,0 +1,28 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7e0568e..48dd899 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,6 +76,7 @@ Telegram chat: http://t.me/Agorise\nEmail: Agorise@protonmail.ch\nOpen Source: https://github.com/Agorise + Block: %1$s Coming soon Net Worth Search