Added support for message subscriptions on the single connection mode

develop
Nelson R. Perez 2018-08-30 22:32:50 -05:00
parent d2390b0a45
commit 7e2ef7b705
12 changed files with 312 additions and 91 deletions

View File

@ -9,6 +9,7 @@ import android.preference.PreferenceManager;
import android.util.Log;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import java.io.Serializable;
@ -22,6 +23,8 @@ import cy.agorise.graphenej.Asset;
import cy.agorise.graphenej.AssetAmount;
import cy.agorise.graphenej.LimitOrder;
import cy.agorise.graphenej.RPC;
import cy.agorise.graphenej.Transaction;
import cy.agorise.graphenej.UserAccount;
import cy.agorise.graphenej.api.ApiAccess;
import cy.agorise.graphenej.api.ConnectionStatusUpdate;
import cy.agorise.graphenej.api.bitshares.Nodes;
@ -38,8 +41,14 @@ import cy.agorise.graphenej.models.ApiCall;
import cy.agorise.graphenej.models.Block;
import cy.agorise.graphenej.models.BlockHeader;
import cy.agorise.graphenej.models.BucketObject;
import cy.agorise.graphenej.models.DynamicGlobalProperties;
import cy.agorise.graphenej.models.JsonRpcNotification;
import cy.agorise.graphenej.models.JsonRpcResponse;
import cy.agorise.graphenej.models.OperationHistory;
import cy.agorise.graphenej.objects.Memo;
import cy.agorise.graphenej.operations.CustomOperation;
import cy.agorise.graphenej.operations.LimitOrderCreateOperation;
import cy.agorise.graphenej.operations.TransferOperation;
import io.reactivex.annotations.Nullable;
import okhttp3.OkHttpClient;
import okhttp3.Request;
@ -95,7 +104,18 @@ public class NetworkService extends Service {
private ArrayList<String> mNodeUrls = new ArrayList<>();
private Gson gson = new Gson();
private Gson gson = new GsonBuilder()
.registerTypeAdapter(Transaction.class, new Transaction.TransactionDeserializer())
.registerTypeAdapter(TransferOperation.class, new TransferOperation.TransferDeserializer())
.registerTypeAdapter(LimitOrderCreateOperation.class, new LimitOrderCreateOperation.LimitOrderCreateDeserializer())
.registerTypeAdapter(CustomOperation.class, new CustomOperation.CustomOperationDeserializer())
.registerTypeAdapter(AssetAmount.class, new AssetAmount.AssetAmountDeserializer())
.registerTypeAdapter(UserAccount.class, new UserAccount.UserAccountSimpleDeserializer())
.registerTypeAdapter(DynamicGlobalProperties.class, new DynamicGlobalProperties.DynamicGlobalPropertiesDeserializer())
.registerTypeAdapter(Memo.class, new Memo.MemoDeserializer())
.registerTypeAdapter(OperationHistory.class, new OperationHistory.OperationHistoryDeserializer())
.registerTypeAdapter(JsonRpcNotification.class, new JsonRpcNotification.JsonRpcNotificationDeserializer())
.create();
// Map used to keep track of outgoing request ids and its request types. This is just
// one of two required mappings. The second one is implemented by the DeserializationMap
@ -287,10 +307,32 @@ public class NetworkService extends Service {
mLastCall = "";
}
}
// Properly de-serialize all other fields and broadcasts to the event bus
handleJsonRpcResponse(response, text);
}else{
Log.w(TAG,"Error.Msg: "+response.error.message);
// If no 'result' field was found, this incoming message probably corresponds to a
// JSON-RPC notification message, which should have a 'method' field with the string
// 'notice' as its value
JsonRpcNotification notification = gson.fromJson(text, JsonRpcNotification.class);
if(notification.method != null && notification.method.equals("notice")){
handleJsonRpcNotification(notification);
}else{
if(response.error != null && response.error.message != null){
// We could not make sense of this incoming message, just log a warning
Log.w(TAG,"Error.Msg: "+response.error.message);
}
}
}
}
/**
* Private method that will de-serialize all fields of every kind of JSON-RPC response
* and broadcast it to the event bus.
*
* @param response De-serialized response
* @param text Raw text, as received
*/
private void handleJsonRpcResponse(JsonRpcResponse response, String text){
JsonRpcResponse parsedResponse = null;
Class requestClass = mRequestClassMap.get(response.id);
@ -355,6 +397,15 @@ public class NetworkService extends Service {
RxBus.getBusInstance().send(parsedResponse);
}
/**
* Private method that will just broadcast a de-serialized notification to all interested parties
* @param notification De-serialized notification
*/
private void handleJsonRpcNotification(JsonRpcNotification notification){
// Broadcasting the parsed notification to all interested listeners
RxBus.getBusInstance().send(notification);
}
/**
* Method used to try to deserialize a 'get_objects' API call. Since this request can be used
* for several types of objects, the de-serialization procedure can be a bit more complex.

View File

@ -0,0 +1,17 @@
package cy.agorise.graphenej.api.calls;
import java.io.Serializable;
import java.util.ArrayList;
import cy.agorise.graphenej.RPC;
import cy.agorise.graphenej.api.ApiAccess;
import cy.agorise.graphenej.models.ApiCall;
public class CancelAllSubscriptions implements ApiCallable {
public static final int REQUIRED_API = ApiAccess.API_DATABASE;
@Override
public ApiCall toApiCall(int apiId, long sequenceId) {
return new ApiCall(apiId, RPC.CALL_CANCEL_ALL_SUBSCRIPTIONS, new ArrayList<Serializable>(), RPC.VERSION, sequenceId);
}
}

View File

@ -0,0 +1,26 @@
package cy.agorise.graphenej.api.calls;
import java.io.Serializable;
import java.util.ArrayList;
import cy.agorise.graphenej.RPC;
import cy.agorise.graphenej.api.ApiAccess;
import cy.agorise.graphenej.models.ApiCall;
public class SetSubscribeCallback implements ApiCallable {
public static final int REQUIRED_API = ApiAccess.API_DATABASE;
private boolean clearFilter;
public SetSubscribeCallback(boolean clearFilter){
this.clearFilter = clearFilter;
}
@Override
public ApiCall toApiCall(int apiId, long sequenceId) {
ArrayList<Serializable> subscriptionParams = new ArrayList<>();
subscriptionParams.add(String.format("%d", sequenceId));
subscriptionParams.add(clearFilter);
return new ApiCall(apiId, RPC.CALL_SET_SUBSCRIBE_CALLBACK, subscriptionParams, RPC.VERSION, sequenceId);
}
}

View File

@ -0,0 +1,101 @@
package cy.agorise.graphenej.models;
import com.google.gson.JsonArray;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import java.io.Serializable;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
import cy.agorise.graphenej.GrapheneObject;
import cy.agorise.graphenej.ObjectType;
import cy.agorise.graphenej.OperationType;
import cy.agorise.graphenej.Transaction;
/**
* Class that represents a generic subscription notification.
* The template for every subscription response is the following:
*
* {
* "method": "notice"
* "params": [
* SUBSCRIPTION_ID,
* [[
* { "id": "2.1.0", ... },
* { "id": ... },
* { "id": ... },
* { "id": ... }
* ]]
* ],
* }
*/
public class JsonRpcNotification {
public static final String KEY_METHOD = "method";
public static final String KEY_PARAMS = "params";
public String method;
public List<Serializable> params;
/**
* Inner static class used to parse and deserialize subscription notifications.
*/
public static class JsonRpcNotificationDeserializer implements JsonDeserializer<JsonRpcNotification> {
@Override
public JsonRpcNotification deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
JsonRpcNotification notification = new JsonRpcNotification();
JsonObject responseObject = json.getAsJsonObject();
if(!responseObject.has(KEY_METHOD)){
return notification;
}
notification.method = responseObject.get(KEY_METHOD).getAsString();
JsonArray paramsArray = responseObject.get(KEY_PARAMS).getAsJsonArray();
notification.params = new ArrayList<>();
notification.params.add(paramsArray.get(0).getAsInt());
ArrayList<Serializable> secondArgument = new ArrayList<>();
notification.params.add(secondArgument);
JsonArray subArray = paramsArray.get(1).getAsJsonArray().get(0).getAsJsonArray();
for(JsonElement object : subArray){
if(object.isJsonObject()){
GrapheneObject grapheneObject = new GrapheneObject(object.getAsJsonObject().get(GrapheneObject.KEY_ID).getAsString());
JsonObject jsonObject = object.getAsJsonObject();
if(grapheneObject.getObjectType() == ObjectType.ACCOUNT_BALANCE_OBJECT){
AccountBalanceUpdate balanceObject = new AccountBalanceUpdate(grapheneObject.getObjectId());
balanceObject.owner = jsonObject.get(AccountBalanceUpdate.KEY_OWNER).getAsString();
balanceObject.asset_type = jsonObject.get(AccountBalanceUpdate.KEY_ASSET_TYPE).getAsString();
balanceObject.balance = jsonObject.get(AccountBalanceUpdate.KEY_BALANCE).getAsLong();
secondArgument.add(balanceObject);
}else if(grapheneObject.getObjectType() == ObjectType.DYNAMIC_GLOBAL_PROPERTY_OBJECT){
DynamicGlobalProperties dynamicGlobalProperties = context.deserialize(object, DynamicGlobalProperties.class);
secondArgument.add(dynamicGlobalProperties);
}else if(grapheneObject.getObjectType() == ObjectType.TRANSACTION_OBJECT){
BroadcastedTransaction broadcastedTransaction = new BroadcastedTransaction(grapheneObject.getObjectId());
broadcastedTransaction.setTransaction((Transaction) context.deserialize(jsonObject.get(BroadcastedTransaction.KEY_TRX), Transaction.class));
broadcastedTransaction.setTransactionId(jsonObject.get(BroadcastedTransaction.KEY_TRX_ID).getAsString());
secondArgument.add(broadcastedTransaction);
}else if(grapheneObject.getObjectType() == ObjectType.OPERATION_HISTORY_OBJECT){
if(jsonObject.get(OperationHistory.KEY_OP).getAsJsonArray().get(0).getAsLong() == OperationType.TRANSFER_OPERATION.ordinal()){
OperationHistory operationHistory = context.deserialize(jsonObject, OperationHistory.class);
secondArgument.add(operationHistory);
}else{
//TODO: Add support for other operations
}
}else{
//TODO: Add support for other types of objects
}
}else{
secondArgument.add(object.getAsString());
}
}
return notification;
}
}
}

View File

@ -44,15 +44,12 @@ import cy.agorise.graphenej.interfaces.SubscriptionListener;
* To minimize CPU usage, we introduce a scheme of selective parsing, implemented by the static inner class
* SubscriptionResponseDeserializer.
*
* Created by nelson on 1/12/17.
*/
public class SubscriptionResponse {
private static final String TAG = "SubscriptionResponse";
public static final String KEY_ID = "id";
public static final String KEY_METHOD = "method";
public static final String KEY_PARAMS = "params";
public int id;
public String method;
public List<Serializable> params;

View File

@ -0,0 +1,59 @@
package cy.agorise.graphenej.models;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.junit.Assert;
import org.junit.Test;
import java.io.Serializable;
import java.util.ArrayList;
import cy.agorise.graphenej.AssetAmount;
import cy.agorise.graphenej.Transaction;
import cy.agorise.graphenej.UserAccount;
import cy.agorise.graphenej.objects.Memo;
import cy.agorise.graphenej.operations.CustomOperation;
import cy.agorise.graphenej.operations.LimitOrderCreateOperation;
import cy.agorise.graphenej.operations.TransferOperation;
public class JsonRpcNotificationTest {
private String text = "{\"method\":\"notice\",\"params\":[3,[[{\"id\":\"2.1.0\",\"head_block_number\":30071834,\"head_block_id\":\"01cadc1a5f3f517e2eba9588111aef3af3c59916\",\"time\":\"2018-08-30T18:19:45\",\"current_witness\":\"1.6.74\",\"next_maintenance_time\":\"2018-08-30T19:00:00\",\"last_budget_time\":\"2018-08-30T18:00:00\",\"witness_budget\":80800000,\"accounts_registered_this_interval\":9,\"recently_missed_count\":0,\"current_aslot\":30228263,\"recent_slots_filled\":\"340282366920938463463374607431768211455\",\"dynamic_flags\":0,\"last_irreversible_block_num\":30071813}]]]}";
@Test
public void failResponseDeserialization(){
Gson gson = new Gson();
JsonRpcResponse<?> response = gson.fromJson(text, JsonRpcResponse.class);
// The result field of this de-serialized object should be null
Assert.assertNull(response.result);
}
@Test
public void succeedNotificationDeserialization(){
Gson gson = new GsonBuilder()
.registerTypeAdapter(Transaction.class, new Transaction.TransactionDeserializer())
.registerTypeAdapter(TransferOperation.class, new TransferOperation.TransferDeserializer())
.registerTypeAdapter(LimitOrderCreateOperation.class, new LimitOrderCreateOperation.LimitOrderCreateDeserializer())
.registerTypeAdapter(CustomOperation.class, new CustomOperation.CustomOperationDeserializer())
.registerTypeAdapter(AssetAmount.class, new AssetAmount.AssetAmountDeserializer())
.registerTypeAdapter(UserAccount.class, new UserAccount.UserAccountSimpleDeserializer())
.registerTypeAdapter(DynamicGlobalProperties.class, new DynamicGlobalProperties.DynamicGlobalPropertiesDeserializer())
.registerTypeAdapter(Memo.class, new Memo.MemoDeserializer())
.registerTypeAdapter(OperationHistory.class, new OperationHistory.OperationHistoryDeserializer())
.registerTypeAdapter(JsonRpcNotification.class, new JsonRpcNotification.JsonRpcNotificationDeserializer())
.create();
JsonRpcNotification notification = gson.fromJson(text, JsonRpcNotification.class);
// Should deserialize a 'params' array with 2 elements
Assert.assertEquals(2, notification.params.size());
// The first element should be the number 3
Assert.assertEquals(3, notification.params.get(0));
ArrayList<Serializable> secondArgument = (ArrayList<Serializable>) notification.params.get(1);
// The second element should be an array of length 1
Assert.assertEquals(1, secondArgument.size());
// Extracting the payload, which should be in itself another array
DynamicGlobalProperties payload = (DynamicGlobalProperties) secondArgument.get(0);
// Dynamic global properties head_block_number should match
Assert.assertEquals(30071834, payload.head_block_number);
}
}

View File

@ -0,0 +1,20 @@
package cy.agorise.graphenej.models;
import com.google.gson.Gson;
import junit.framework.Assert;
import org.junit.Test;
public class JsonRpcResponseTest {
@Test
public void deserializeJsonRpcResponse(){
String text = "{\"id\":4,\"jsonrpc\":\"2.0\",\"result\":[{\"id\":\"2.1.0\",\"head_block_number\":30071833,\"head_block_id\":\"01cadc1964cb04ab551463e26033ab0f159bc8e1\",\"time\":\"2018-08-30T18:19:42\",\"current_witness\":\"1.6.71\",\"next_maintenance_time\":\"2018-08-30T19:00:00\",\"last_budget_time\":\"2018-08-30T18:00:00\",\"witness_budget\":80900000,\"accounts_registered_this_interval\":9,\"recently_missed_count\":0,\"current_aslot\":30228262,\"recent_slots_filled\":\"340282366920938463463374607431768211455\",\"dynamic_flags\":0,\"last_irreversible_block_num\":30071813}]}";
Gson gson = new Gson();
JsonRpcResponse<?> response = gson.fromJson(text, JsonRpcResponse.class);
System.out.println("response: "+response.result);
Assert.assertNotNull(response);
Assert.assertNotNull(response.result);
}
}

View File

@ -12,11 +12,10 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".SecondActivity" />
<activity android:name=".SubscriptionActivity" />
<activity android:name=".CallsActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

View File

@ -16,6 +16,7 @@ import butterknife.ButterKnife;
import cy.agorise.graphenej.RPC;
public class CallsActivity extends AppCompatActivity {
private final String TAG = this.getClass().getName();
@BindView(R.id.call_list)
RecyclerView mRecyclerView;
@ -45,7 +46,8 @@ public class CallsActivity extends AppCompatActivity {
RPC.CALL_LOOKUP_ASSET_SYMBOLS,
RPC.CALL_LIST_ASSETS,
RPC.CALL_GET_ACCOUNT_BY_NAME,
RPC.CALL_GET_LIMIT_ORDERS
RPC.CALL_GET_LIMIT_ORDERS,
RPC.CALL_SET_SUBSCRIBE_CALLBACK
};
@NonNull
@ -62,9 +64,14 @@ public class CallsActivity extends AppCompatActivity {
String formattedName = name.replace("_", " ").toUpperCase();
holder.mCallNameView.setText(formattedName);
holder.mCallNameView.setOnClickListener((view) -> {
Intent intent = new Intent(CallsActivity.this, PerformCallActivity.class);
String selectedCall = ((TextView)view).getText().toString().replace(" ", "_").toLowerCase();
intent.putExtra(Constants.KEY_SELECTED_CALL, selectedCall);
String selectedCall = supportedCalls[position];
Intent intent;
if(selectedCall.equals(RPC.CALL_SET_SUBSCRIBE_CALLBACK)){
intent = new Intent(CallsActivity.this, SubscriptionActivity.class);
}else{
intent = new Intent(CallsActivity.this, PerformCallActivity.class);
intent.putExtra(Constants.KEY_SELECTED_CALL, selectedCall);
}
startActivity(intent);
});
}

View File

@ -18,6 +18,9 @@ public class SampleApplication extends Application {
public void onCreate() {
super.onCreate();
// This variable would hold a list of custom nodes
String customNodes = "wss://mydomain.net/ws,wss://myotherdomain.com/ws";
// Specifying some important information regarding the connection, such as the
// credentials and the requested API accesses
int requestedApis = ApiAccess.API_DATABASE | ApiAccess.API_HISTORY | ApiAccess.API_NETWORK_BROADCAST;
@ -26,6 +29,7 @@ public class SampleApplication extends Application {
.putString(NetworkService.KEY_USERNAME, "nelson")
.putString(NetworkService.KEY_PASSWORD, "secret")
.putInt(NetworkService.KEY_REQUESTED_APIS, requestedApis)
// .putString(NetworkService.KEY_CUSTOM_NODE_URLS, customNodes)
.apply();
/*

View File

@ -11,30 +11,19 @@ import android.util.Log;
import android.view.View;
import android.widget.TextView;
import com.google.common.primitives.UnsignedLong;
import com.google.gson.Gson;
import java.util.ArrayList;
import java.util.List;
import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.OnClick;
import cy.agorise.graphenej.Asset;
import cy.agorise.graphenej.AssetAmount;
import cy.agorise.graphenej.BaseOperation;
import cy.agorise.graphenej.UserAccount;
import cy.agorise.graphenej.api.android.NetworkService;
import cy.agorise.graphenej.api.android.RxBus;
import cy.agorise.graphenej.api.calls.GetRequiredFees;
import cy.agorise.graphenej.models.JsonRpcResponse;
import cy.agorise.graphenej.operations.LimitOrderCreateOperation;
import cy.agorise.graphenej.operations.TransferOperation;
import cy.agorise.graphenej.api.calls.CancelAllSubscriptions;
import cy.agorise.graphenej.api.calls.SetSubscribeCallback;
import cy.agorise.graphenej.models.JsonRpcNotification;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import io.reactivex.functions.Consumer;
public class SecondActivity extends AppCompatActivity {
public class SubscriptionActivity extends AppCompatActivity {
private final String TAG = this.getClass().getName();
@ -44,15 +33,15 @@ public class SecondActivity extends AppCompatActivity {
// In case we want to interact directly with the service
private NetworkService mService;
private Gson gson = new Gson();
private Disposable mDisposable;
// Notification counter
private int counter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_second);
Log.d(TAG,"onCreate");
ButterKnife.bind(this);
@ -66,8 +55,9 @@ public class SecondActivity extends AppCompatActivity {
if(message instanceof String){
Log.d(TAG,"Got text message: "+(message));
mTextField.setText(mTextField.getText() + ((String) message) + "\n");
}else if(message instanceof JsonRpcResponse){
mTextField.setText(mTextField.getText() + gson.toJson(message, JsonRpcResponse.class) + "\n");
}else if(message instanceof JsonRpcNotification){
counter++;
mTextField.setText(String.format("Got %d notifications so far", counter));
}
}
});
@ -111,49 +101,13 @@ public class SecondActivity extends AppCompatActivity {
}
};
@OnClick(R.id.transfer_fee_usd)
@OnClick(R.id.subscribe)
public void onTransferFeeUsdClicked(View v){
List<BaseOperation> operations = getTransferOperation();
mService.sendMessage(new GetRequiredFees(operations, new Asset("1.3.121")), GetRequiredFees.REQUIRED_API);
mService.sendMessage(new SetSubscribeCallback(true), SetSubscribeCallback.REQUIRED_API);
}
@OnClick(R.id.transfer_fee_bts)
@OnClick(R.id.unsubscribe)
public void onTransferFeeBtsClicked(View v){
List<BaseOperation> operations = getTransferOperation();
mService.sendMessage(new GetRequiredFees(operations, new Asset("1.3.0")), GetRequiredFees.REQUIRED_API);
}
@OnClick(R.id.exchange_fee_usd)
public void onExchangeFeeUsdClicked(View v){
List<BaseOperation> operations = getExchangeOperation();
mService.sendMessage(new GetRequiredFees(operations, new Asset("1.3.121")), GetRequiredFees.REQUIRED_API);
}
@OnClick(R.id.exchange_fee_bts)
public void onExchangeFeeBtsClicked(View v){
List<BaseOperation> operations = getExchangeOperation();
mService.sendMessage(new GetRequiredFees(operations, new Asset("1.3.0")), GetRequiredFees.REQUIRED_API);
}
private List<BaseOperation> getTransferOperation(){
TransferOperation transferOperation = new TransferOperation(
new UserAccount("1.2.138632"),
new UserAccount("1.2.129848"),
new AssetAmount(UnsignedLong.ONE, new Asset("1.3.0")));
ArrayList<BaseOperation> operations = new ArrayList();
operations.add(transferOperation);
return operations;
}
public List<BaseOperation> getExchangeOperation() {
LimitOrderCreateOperation operation = new LimitOrderCreateOperation(
new UserAccount("1.2.138632"),
new AssetAmount(UnsignedLong.valueOf(10000), new Asset("1.3.0")),
new AssetAmount(UnsignedLong.valueOf(10), new Asset("1.3.121")),
1000000,
true);
ArrayList<BaseOperation> operations = new ArrayList();
operations.add(operation);
return operations;
mService.sendMessage(new CancelAllSubscriptions(), CancelAllSubscriptions.REQUIRED_API);
}
}

View File

@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="cy.agorise.labs.sample.SecondActivity">
tools:context="cy.agorise.labs.sample.SubscriptionActivity">
<TextView
android:id="@+id/text_field"
android:layout_width="0dp"
@ -18,35 +18,21 @@
android:id="@+id/buttons_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:weightSum="4"
android:weightSum="2"
app:layout_constraintBottom_toBottomOf="parent">
<Button
android:id="@+id/transfer_fee_usd"
android:id="@+id/subscribe"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="11sp"
android:text="Transfer (USD)"/>
android:text="Subscribe"/>
<Button
android:id="@+id/transfer_fee_bts"
android:id="@+id/unsubscribe"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="11sp"
android:text="Transfer (BTS)"/>
<Button
android:id="@+id/exchange_fee_usd"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="11sp"
android:text="Exchange (USD)"/>
<Button
android:id="@+id/exchange_fee_bts"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="11sp"
android:text="Exchange (BTS)"/>
android:text="Unsubscribe"/>
</LinearLayout>
</android.support.constraint.ConstraintLayout>