diff --git a/graphenej/src/main/java/cy/agorise/graphenej/api/android/NetworkService.java b/graphenej/src/main/java/cy/agorise/graphenej/api/android/NetworkService.java index 2b2f7a2..1b8a44b 100644 --- a/graphenej/src/main/java/cy/agorise/graphenej/api/android/NetworkService.java +++ b/graphenej/src/main/java/cy/agorise/graphenej/api/android/NetworkService.java @@ -80,7 +80,7 @@ public class NetworkService extends Service { private final String TAG = this.getClass().getName(); public static final int NORMAL_CLOSURE_STATUS = 1000; - private static final int NO_HISTORY_CLOSURE_STATUS = 1001; + private static final int GOING_AWAY_STATUS = 1001; // Time to wait before retrying a connection attempt private static final int DEFAULT_RETRY_DELAY = 500; @@ -341,6 +341,14 @@ public class NetworkService extends Service { } } + /** + * Public method that can be called from classes that bind to the service and find out that + * for any reason want the service to connect to a different node. + */ + public void removeCurrentNodeAndReconnect() { + mWebSocket.close(GOING_AWAY_STATUS, null); + } + /** * Runnable that will perform a connection attempt with the best node after DEFAULT_INITIAL_DELAY * milliseconds. This is used only if the node latency verification is activated. @@ -566,9 +574,6 @@ public class NetworkService extends Service { } else if (requestClass == GetFullAccounts.class) { Type GetFullAccountsResponse = new TypeToken>>(){}.getType(); parsedResponse = gson.fromJson(text, GetFullAccountsResponse); - - if(parsedResponse != null) - verifyNodeHasHistoryApi(parsedResponse); } else if(requestClass == GetKeyReferences.class){ Type GetKeyReferencesResponse = new TypeToken>>>(){}.getType(); parsedResponse = gson.fromJson(text, GetKeyReferencesResponse); @@ -591,30 +596,6 @@ public class NetworkService extends Service { RxBus.getBusInstance().send(parsedResponse); } - /** - * This method inspects the node response to find out if the totalOps is equal to zero, - * in that case the current connected node may not have the history plugin so we would need - * to close the connection and choose a different node. - * - * @param parsedResponse A JSONRpcResponse from a GetFullAccounts API call - */ - private void verifyNodeHasHistoryApi(JsonRpcResponse parsedResponse) { - if(parsedResponse.result instanceof List && - ((List) parsedResponse.result).size() > 0 && - ((List) parsedResponse.result).get(0) instanceof FullAccountDetails) { - - FullAccountDetails fullAccountDetails = (FullAccountDetails) ((List) parsedResponse.result).get(0); - long totalOps = fullAccountDetails.getStatistics().total_ops; - - if (totalOps == 0) { - Log.d(TAG, "The node returned 0 total_ops for current account and may not have installed the history plugin. " + - "Trying to connect to a different node."); - - mWebSocket.close(NO_HISTORY_CLOSURE_STATUS, null); - } - } - } - /** * Private method that will just broadcast a de-serialized notification to all interested parties * @param notification De-serialized notification @@ -674,10 +655,10 @@ public class NetworkService extends Service { super.onClosed(webSocket, code, reason); Log.d(TAG,"onClosed"); - if (code == NO_HISTORY_CLOSURE_STATUS) - handleWebSocketDisconnection(true); + if (code == GOING_AWAY_STATUS) + handleWebSocketDisconnection(true, true); else - handleWebSocketDisconnection(false); + handleWebSocketDisconnection(false, false); } @Override @@ -694,16 +675,17 @@ public class NetworkService extends Service { Log.e(TAG,"Response: "+response.message()); } - handleWebSocketDisconnection(true); + handleWebSocketDisconnection(true, false); } /** * Method that encapsulates the behavior of handling a disconnection to the current node, and * potentially tries to reconnect to another one. * - * @param tryReconnection Variable that states if a reconnection to other node should be tried. + * @param tryReconnection States if a reconnection to other node should be tried. + * @param removeSelectedNode States if the current node should be removed from the nodes list. */ - private void handleWebSocketDisconnection(boolean tryReconnection) { + private void handleWebSocketDisconnection(boolean tryReconnection, boolean removeSelectedNode) { RxBus.getBusInstance().send(new ConnectionStatusUpdate(ConnectionStatusUpdate.DISCONNECTED, ApiAccess.API_NONE)); isLoggedIn = false; @@ -720,10 +702,18 @@ public class NetworkService extends Service { mCurrentId = 0; mApiIds.clear(); - // Adding a very high latency value to this node in order to prevent - // us from getting it again - mSelectedNode.addLatencyValue(Long.MAX_VALUE); - nodeProvider.updateNode(mSelectedNode); + if (removeSelectedNode) { + // Remove node from node provider so that it is not returned for following connections + nodeProvider.removeNode(mSelectedNode); + + // Remove node from nodeLatencyVerifier, so that it publishes its removal + nodeLatencyVerifier.removeNode(mSelectedNode); + } else { + // Adding a very high latency value to this node in order to prevent + // us from getting it again + mSelectedNode.addLatencyValue(Long.MAX_VALUE); + nodeProvider.updateNode(mSelectedNode); + } RxBus.getBusInstance().send(new ConnectionStatusUpdate(ConnectionStatusUpdate.DISCONNECTED, ApiAccess.API_NONE)); diff --git a/graphenej/src/main/java/cy/agorise/graphenej/network/FullNode.java b/graphenej/src/main/java/cy/agorise/graphenej/network/FullNode.java index be91d4a..4226b23 100644 --- a/graphenej/src/main/java/cy/agorise/graphenej/network/FullNode.java +++ b/graphenej/src/main/java/cy/agorise/graphenej/network/FullNode.java @@ -10,6 +10,7 @@ public class FullNode implements Comparable { private String mUrl; private ExponentialMovingAverage mLatency; private boolean isConnected; + private boolean isRemoved; private FullNode(){} @@ -77,6 +78,14 @@ public class FullNode implements Comparable { isConnected = connected; } + public boolean isRemoved() { + return isRemoved; + } + + public void setRemoved(boolean removed) { + isRemoved = removed; + } + /** * Method that updates the mLatency average with a new value. * @param latency Most recent mLatency sample to be added to the exponential average diff --git a/graphenej/src/main/java/cy/agorise/graphenej/network/LatencyNodeProvider.java b/graphenej/src/main/java/cy/agorise/graphenej/network/LatencyNodeProvider.java index 1e12939..92bf2c5 100644 --- a/graphenej/src/main/java/cy/agorise/graphenej/network/LatencyNodeProvider.java +++ b/graphenej/src/main/java/cy/agorise/graphenej/network/LatencyNodeProvider.java @@ -3,13 +3,14 @@ package cy.agorise.graphenej.network; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.PriorityQueue; +import java.util.concurrent.PriorityBlockingQueue; public class LatencyNodeProvider implements NodeProvider { - private PriorityQueue mFullNodeHeap; + + private PriorityBlockingQueue mFullNodeHeap; public LatencyNodeProvider(){ - mFullNodeHeap = new PriorityQueue<>(); + mFullNodeHeap = new PriorityBlockingQueue<>(); } @Override @@ -24,8 +25,11 @@ public class LatencyNodeProvider implements NodeProvider { @Override public boolean updateNode(FullNode fullNode) { - mFullNodeHeap.remove(fullNode); - return mFullNodeHeap.offer(fullNode); + boolean existed = mFullNodeHeap.remove(fullNode); + if(existed){ + return mFullNodeHeap.offer(fullNode); + } + return false; } /** @@ -36,12 +40,16 @@ public class LatencyNodeProvider implements NodeProvider { * @return True if the node priority was updated successfully */ public boolean updateNode(FullNode fullNode, int latency){ - if(mFullNodeHeap.remove(fullNode)){ - fullNode.addLatencyValue(latency); + boolean existed = mFullNodeHeap.remove(fullNode); + if(existed){ return mFullNodeHeap.add(fullNode); - }else{ - return false; } + return false; + } + + @Override + public void removeNode(FullNode fullNode) { + mFullNodeHeap.remove(fullNode); } @Override diff --git a/graphenej/src/main/java/cy/agorise/graphenej/network/NodeLatencyVerifier.java b/graphenej/src/main/java/cy/agorise/graphenej/network/NodeLatencyVerifier.java index 0b97301..076ddfd 100644 --- a/graphenej/src/main/java/cy/agorise/graphenej/network/NodeLatencyVerifier.java +++ b/graphenej/src/main/java/cy/agorise/graphenej/network/NodeLatencyVerifier.java @@ -183,4 +183,24 @@ public class NodeLatencyVerifier { } } } + + /** + * Removes the given node from the nodes list + * @param fullNode The node to remove + */ + public void removeNode(FullNode fullNode){ + for(FullNode node : mNodeList){ + if(node.equals(fullNode)){ + mNodeList.remove(node); + + String normalURL = node.getUrl().replace("wss://", "https://"); + HttpUrl key = HttpUrl.parse(normalURL); + nodeURLMap.remove(key); + + node.setRemoved(true); + subject.onNext(node); + break; + } + } + } } diff --git a/graphenej/src/main/java/cy/agorise/graphenej/network/NodeProvider.java b/graphenej/src/main/java/cy/agorise/graphenej/network/NodeProvider.java index 6f3821e..400158e 100644 --- a/graphenej/src/main/java/cy/agorise/graphenej/network/NodeProvider.java +++ b/graphenej/src/main/java/cy/agorise/graphenej/network/NodeProvider.java @@ -26,13 +26,19 @@ public interface NodeProvider { /** * Updates the rating of a specific node that is already in the NodeProvider - * @param fullNode + * @param fullNode The node tu update */ boolean updateNode(FullNode fullNode); + /** + * Removes the given node from the nodes list + * @param fullNode The node to remove + */ + void removeNode(FullNode fullNode); + /** * Returns an ordered list of {@link FullNode} instances. - * @return + * @return The sorted list of nodes. */ List getSortedNodes(); } diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index de61cce..62c9b15 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ @@ -11,7 +12,8 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/AppTheme"> + android:theme="@style/AppTheme" + tools:ignore="GoogleAppIndexingWarning"> @@ -19,7 +21,8 @@ - + + \ No newline at end of file diff --git a/sample/src/main/java/cy/agorise/labs/sample/CallsActivity.java b/sample/src/main/java/cy/agorise/labs/sample/CallsActivity.java index bcd8ab8..28f2e0d 100644 --- a/sample/src/main/java/cy/agorise/labs/sample/CallsActivity.java +++ b/sample/src/main/java/cy/agorise/labs/sample/CallsActivity.java @@ -25,6 +25,8 @@ import io.reactivex.functions.Consumer; public class CallsActivity extends AppCompatActivity { private final String TAG = this.getClass().getName(); + private static final String REMOVE_CURRENT_NODE = "remove_current_node"; + @BindView(R.id.call_list) RecyclerView mRecyclerView; @@ -75,7 +77,8 @@ public class CallsActivity extends AppCompatActivity { RPC.CALL_SET_SUBSCRIBE_CALLBACK, RPC.CALL_GET_DYNAMIC_GLOBAL_PROPERTIES, RPC.CALL_GET_KEY_REFERENCES, - RPC.CALL_GET_ACCOUNT_BALANCES + RPC.CALL_GET_ACCOUNT_BALANCES, + REMOVE_CURRENT_NODE }; @NonNull @@ -96,6 +99,8 @@ public class CallsActivity extends AppCompatActivity { Intent intent; if(selectedCall.equals(RPC.CALL_SET_SUBSCRIBE_CALLBACK)){ intent = new Intent(CallsActivity.this, SubscriptionActivity.class); + } else if (selectedCall.equals(REMOVE_CURRENT_NODE)){ + intent = new Intent(CallsActivity.this, RemoveNodeActivity.class); }else{ intent = new Intent(CallsActivity.this, PerformCallActivity.class); intent.putExtra(Constants.KEY_SELECTED_CALL, selectedCall); diff --git a/sample/src/main/java/cy/agorise/labs/sample/RemoveNodeActivity.java b/sample/src/main/java/cy/agorise/labs/sample/RemoveNodeActivity.java new file mode 100644 index 0000000..2ab9954 --- /dev/null +++ b/sample/src/main/java/cy/agorise/labs/sample/RemoveNodeActivity.java @@ -0,0 +1,286 @@ +package cy.agorise.labs.sample; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.graphics.Color; +import android.graphics.Typeface; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.support.annotation.NonNull; +import android.support.v4.content.ContextCompat; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.util.SortedList; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.OnClick; +import cy.agorise.graphenej.api.android.NetworkService; +import cy.agorise.graphenej.network.FullNode; +import io.reactivex.Observer; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.subjects.PublishSubject; + +public class RemoveNodeActivity extends AppCompatActivity implements ServiceConnection { + + private final String TAG = this.getClass().getName(); + + @BindView(R.id.rvNodes) + RecyclerView rvNodes; + + FullNodesAdapter nodesAdapter; + + // Comparator used to sort the nodes in ascending order + private final Comparator LATENCY_COMPARATOR = (a, b) -> + Double.compare(a.getLatencyValue(), b.getLatencyValue()); + + /* Network service connection */ + private NetworkService mNetworkService; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_remove_node); + + ButterKnife.bind(this); + + rvNodes.setLayoutManager(new LinearLayoutManager(this)); + nodesAdapter = new FullNodesAdapter(this, LATENCY_COMPARATOR); + rvNodes.setAdapter(nodesAdapter); + } + + @OnClick(R.id.btnRemoveCurrentNode) + public void removeCurrentNode() { + mNetworkService.removeCurrentNodeAndReconnect(); + } + + @Override + public void onServiceConnected(ComponentName componentName, IBinder iBinder) { + // We've bound to LocalService, cast the IBinder and get LocalService instance + NetworkService.LocalBinder binder = (NetworkService.LocalBinder) iBinder; + mNetworkService = binder.getService(); + + if(mNetworkService != null){ + // PublishSubject used to announce full node latencies updates + PublishSubject fullNodePublishSubject = mNetworkService.getNodeLatencyObservable(); + if(fullNodePublishSubject != null) + fullNodePublishSubject.observeOn(AndroidSchedulers.mainThread()).subscribe(nodeLatencyObserver); + + List fullNodes = mNetworkService.getNodes(); + nodesAdapter.add(fullNodes); + } + } + + @Override + public void onServiceDisconnected(ComponentName componentName) { + mNetworkService = null; + } + + /** + * Observer used to be notified about node latency measurement updates. + */ + private Observer nodeLatencyObserver = new Observer() { + @Override + public void onSubscribe(Disposable d) { } + + @Override + public void onNext(FullNode fullNode) { + if (!fullNode.isRemoved()) + nodesAdapter.add(fullNode); + else + nodesAdapter.remove(fullNode); + } + + @Override + public void onError(Throwable e) { + Log.e(TAG,"nodeLatencyObserver.onError.Msg: "+e.getMessage()); + } + + @Override + public void onComplete() { } + }; + + @Override + protected void onStart() { + super.onStart(); + // Bind to LocalService + Intent intent = new Intent(this, NetworkService.class); + bindService(intent, this, Context.BIND_AUTO_CREATE); + } + + @Override + protected void onPause() { + super.onPause(); + unbindService(this); + } + + class FullNodesAdapter extends RecyclerView.Adapter { + + class ViewHolder extends RecyclerView.ViewHolder { + ImageView ivNodeStatus; + TextView tvNodeName; + + ViewHolder(View itemView) { + super(itemView); + + ivNodeStatus = itemView.findViewById(R.id.ivNodeStatus); + tvNodeName = itemView.findViewById(R.id.tvNodeName); + } + } + + private final SortedList mSortedList = new SortedList<>(FullNode.class, new SortedList.Callback() { + @Override + public void onInserted(int position, int count) { + notifyItemRangeInserted(position, count); + } + + @Override + public void onRemoved(int position, int count) { + notifyItemRangeRemoved(position, count); + } + + @Override + public void onMoved(int fromPosition, int toPosition) { + notifyItemMoved(fromPosition, toPosition); + } + + @Override + public void onChanged(int position, int count) { + notifyItemRangeChanged(position, count); + } + + @Override + public int compare(FullNode a, FullNode b) { + return mComparator.compare(a, b); + } + + @Override + public boolean areContentsTheSame(FullNode oldItem, FullNode newItem) { + return oldItem.getLatencyValue() == newItem.getLatencyValue(); + } + + @Override + public boolean areItemsTheSame(FullNode item1, FullNode item2) { + return item1.getUrl().equals(item2.getUrl()); + } + }); + + private final Comparator mComparator; + + private Context mContext; + + FullNodesAdapter(Context context, Comparator comparator) { + mContext = context; + mComparator = comparator; + } + + private Context getContext() { + return mContext; + } + + @NonNull + @Override + public FullNodesAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + Context context = parent.getContext(); + LayoutInflater inflater = LayoutInflater.from(context); + + View transactionView = inflater.inflate(R.layout.item_node, parent, false); + + return new ViewHolder(transactionView); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder viewHolder, int position) { + final FullNode fullNode = mSortedList.get(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); + + double latency = fullNode.getLatencyValue(); + + // Select correct color span according to the latency value + ForegroundColorSpan colorSpan; + + if (latency < 400) + colorSpan = new ForegroundColorSpan(ContextCompat.getColor(getContext(), R.color.colorPrimary)); + else if (latency < 800) + colorSpan = new ForegroundColorSpan(Color.rgb(255,136,0)); // Holo orange + else + colorSpan = new ForegroundColorSpan(Color.rgb(204,0,0)); // Holo red + + // Create a string with the latency number colored according to their amount + SpannableStringBuilder ssb = new SpannableStringBuilder(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + ssb.append(fullNode.getUrl().replace("wss://", ""), new 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. + String ms = latency < 2000 ? String.format(Locale.US, "%.0f ms", latency) : "??"; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + ssb.append(ms, colorSpan, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + ssb.append(")"); + + viewHolder.tvNodeName.setText(ssb); + } + + /** + * Functions that adds/updates a FullNode to the SortedList + */ + public void 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. + int removed = 0; + for (int i=0; i fullNodes) { + mSortedList.addAll(fullNodes); + } + + public void remove(FullNode fullNode) { + mSortedList.remove(fullNode); + } + + @Override + public int getItemCount() { + return mSortedList.size(); + } + } +} diff --git a/sample/src/main/res/drawable/ic_connected.xml b/sample/src/main/res/drawable/ic_connected.xml new file mode 100644 index 0000000..5b57096 --- /dev/null +++ b/sample/src/main/res/drawable/ic_connected.xml @@ -0,0 +1,5 @@ + + + diff --git a/sample/src/main/res/layout/activity_calls.xml b/sample/src/main/res/layout/activity_calls.xml index e2f87a0..10e0e6c 100644 --- a/sample/src/main/res/layout/activity_calls.xml +++ b/sample/src/main/res/layout/activity_calls.xml @@ -4,6 +4,6 @@ android:id="@+id/call_list" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".CallsActivity"> - - \ No newline at end of file + tools:context=".CallsActivity" + tools:listitem="@layout/item_call" + tools:itemCount="5"/> \ No newline at end of file diff --git a/sample/src/main/res/layout/activity_perform_call.xml b/sample/src/main/res/layout/activity_perform_call.xml index 1a44ab0..58c16ae 100644 --- a/sample/src/main/res/layout/activity_perform_call.xml +++ b/sample/src/main/res/layout/activity_perform_call.xml @@ -11,8 +11,6 @@ android:layout_width="match_parent" android:layout_height="0dp" android:layout_marginEnd="8dp" - android:layout_marginLeft="8dp" - android:layout_marginRight="8dp" android:layout_marginStart="8dp" android:layout_marginTop="8dp" app:layout_constraintBottom_toTopOf="@+id/container_param1" @@ -32,8 +30,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginEnd="8dp" - android:layout_marginLeft="8dp" - android:layout_marginRight="8dp" android:layout_marginStart="8dp" app:layout_constraintBottom_toTopOf="@+id/container_param2" app:layout_constraintEnd_toEndOf="parent" @@ -51,8 +47,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginEnd="8dp" - android:layout_marginLeft="8dp" - android:layout_marginRight="8dp" android:layout_marginStart="8dp" app:layout_constraintBottom_toTopOf="@+id/container_param3" app:layout_constraintEnd_toEndOf="parent" @@ -69,8 +63,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginEnd="8dp" - android:layout_marginLeft="8dp" - android:layout_marginRight="8dp" android:layout_marginStart="8dp" app:layout_constraintBottom_toTopOf="@+id/container_param4" app:layout_constraintEnd_toEndOf="parent" @@ -87,8 +79,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginEnd="8dp" - android:layout_marginLeft="8dp" - android:layout_marginRight="8dp" android:layout_marginStart="8dp" app:layout_constraintBottom_toTopOf="@+id/button_send" app:layout_constraintEnd_toEndOf="parent" @@ -106,8 +96,6 @@ android:layout_height="wrap_content" android:layout_marginBottom="8dp" android:layout_marginEnd="8dp" - android:layout_marginLeft="8dp" - android:layout_marginRight="8dp" android:layout_marginStart="8dp" android:text="@string/action_send" app:layout_constraintBottom_toBottomOf="parent" diff --git a/sample/src/main/res/layout/activity_remove_node.xml b/sample/src/main/res/layout/activity_remove_node.xml new file mode 100644 index 0000000..a1cb381 --- /dev/null +++ b/sample/src/main/res/layout/activity_remove_node.xml @@ -0,0 +1,25 @@ + + + + + +