diff --git a/README.md b/README.md index 0315f489..b79d7f9b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ It contains applications demonstrating Bluetooth Smart profiles: * **Blood Pressure Monitor**, * **Health Thermometer Monitor**, * **Glucose Monitor**, +* **Continuous Glucose Monitor**, * **Proximity Monitor**. Since version 1.10.0 the *nRF Toolbox* also supports the **Nordic UART Service** which may be used for bidirectional text communication between devices. diff --git a/app/build.gradle b/app/build.gradle index 751da1d6..1d837ea4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -8,8 +8,8 @@ android { applicationId "no.nordicsemi.android.nrftoolbox" minSdkVersion 18 targetSdkVersion 24 - versionCode 46 - versionName "1.17.0" + versionCode 47 + versionName "1.18.0" } buildTypes { release { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1cf1e40e..ba0b5038 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,7 +19,7 @@ ~ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ~ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE ~ USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - --> +--> @@ -201,6 +201,16 @@ + + + + + + + + diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMSActivity.java b/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMSActivity.java new file mode 100644 index 00000000..3788f6ca --- /dev/null +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMSActivity.java @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2016, Nordic Semiconductor + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package no.nordicsemi.android.nrftoolbox.cgms; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; +import android.support.v4.content.LocalBroadcastManager; +import android.util.SparseArray; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.ListView; +import android.widget.PopupMenu; +import android.widget.TextView; + +import java.util.UUID; + +import no.nordicsemi.android.nrftoolbox.R; +import no.nordicsemi.android.nrftoolbox.profile.BleProfileService; +import no.nordicsemi.android.nrftoolbox.profile.BleProfileServiceReadyActivity; + +public class CGMSActivity extends BleProfileServiceReadyActivity implements PopupMenu.OnMenuItemClickListener { + + private View mControlPanelStd; + private View mControlPanelAbort; + private TextView mUnitView; + private ListView mRecordsListView; + private CGMSRecordsAdapter mCgmsRecordsAdapter; + + private CGMService.CGMSBinder mBinder; + + @Override + protected void onCreateView(Bundle savedInstanceState) { + setContentView(R.layout.activity_feature_cgms); + setGUI(); + } + + @Override + protected void onInitialize(Bundle savedInstanceState) { + LocalBroadcastManager.getInstance(this).registerReceiver(mBroadcastReceiver, makeIntentFilter()); + } + + private void setGUI() { + mRecordsListView = (ListView) findViewById(R.id.list); + mUnitView = (TextView) findViewById(R.id.unit); + mControlPanelStd = findViewById(R.id.cgms_control_std); + mControlPanelAbort = findViewById(R.id.cgms_control_abort); + + findViewById(R.id.action_last).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + clearRecords(); + if(mBinder != null) { + mBinder.clear(); + mBinder.getLastRecord(); + } + } + }); + findViewById(R.id.action_all).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + clearRecords(); + if(mBinder != null){ + clearRecords(); + mBinder.getAllRecords(); + } + } + }); + findViewById(R.id.action_abort).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if(mBinder != null){ + mBinder.abort(); + } + } + }); + + // create popup menu attached to the button More + findViewById(R.id.action_more).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + PopupMenu menu = new PopupMenu(CGMSActivity.this, v); + menu.setOnMenuItemClickListener(CGMSActivity.this); + MenuInflater inflater = menu.getMenuInflater(); + inflater.inflate(R.menu.gls_more, menu.getMenu()); + menu.show(); + } + }); + } + + private void loadAdapter(SparseArray records){ + for(int i = 0; i < records.size(); i++){ + mCgmsRecordsAdapter.addItem(records.get(i)); + } + mCgmsRecordsAdapter.notifyDataSetChanged(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + LocalBroadcastManager.getInstance(this).unregisterReceiver(mBroadcastReceiver); + } + + @Override + protected void onServiceBinded(CGMService.CGMSBinder binder) { + mBinder = binder; + SparseArray cgmsRecords = binder.getCgmsRecords(); + if(cgmsRecords != null && cgmsRecords.size() > 0){ + if(mCgmsRecordsAdapter == null) { + mCgmsRecordsAdapter = new CGMSRecordsAdapter(CGMSActivity.this); + mRecordsListView.setAdapter(mCgmsRecordsAdapter); + } + loadAdapter(cgmsRecords); + } + } + + @Override + protected void onServiceUnbinded() { + mBinder = null; + } + + @Override + protected Class getServiceClass() { + return CGMService.class; + } + + @Override + protected int getLoggerProfileTitle() { + return R.string.cgms_feature_title; + } + + @Override + protected int getAboutTextId() { + return R.string.cgms_about_text; + } + + @Override + protected int getDefaultDeviceName() { + return R.string.cgms_default_name; + } + + @Override + protected UUID getFilterUUID() { + return CGMSManager.CGMS_UUID; + } + + @Override + public void onServicesDiscovered(final boolean optionalServicesFound) { + // this may notify user or show some views + } + + @Override + public void onDeviceReady() { + + } + + private void setOperationInProgress(final boolean progress) { + runOnUiThread(new Runnable() { + @Override + public void run() { + // setSupportProgressBarIndeterminateVisibility(progress); + mControlPanelStd.setVisibility(!progress ? View.VISIBLE : View.GONE); + mControlPanelAbort.setVisibility(progress ? View.VISIBLE : View.GONE); + } + }); + } + + @Override + public void onDeviceDisconnected() { + super.onDeviceDisconnected(); + setOperationInProgress(false); + clearRecords(); + } + + @Override + protected void setDefaultUI() { + + } + + @Override + public boolean onMenuItemClick(MenuItem menuItem) { + switch (menuItem.getItemId()) { + case R.id.action_refresh: + /*if(mBinder != null) + mBinder.refreshRecords();*/ + break; + case R.id.action_first: + clearRecords(); + if(mBinder != null) + mBinder.getFirstRecord(); + break; + case R.id.action_clear: + clearRecords(); + if(mBinder != null) + mBinder.clear(); + break; + case R.id.action_delete_all: + clearRecords(); + if(mBinder != null) + mBinder.deleteAllRecords(); + break; + } + return true; + } + + private void clearRecords(){ + runOnUiThread(new Runnable() { + @Override + public void run() { + if(mCgmsRecordsAdapter != null){ + mCgmsRecordsAdapter.clear(); + mCgmsRecordsAdapter.notifyDataSetChanged(); + } + } + }); + } + + private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(final Context context, final Intent intent) { + final String action = intent.getAction(); + if (CGMService.BROADCAST_CGMS_VALUES.equals(action)) { + CGMSRecord cgmsRecord = intent.getExtras().getParcelable(CGMService.EXTRA_CGMS_RECORD); + if(mCgmsRecordsAdapter == null){ + mCgmsRecordsAdapter = new CGMSRecordsAdapter(CGMSActivity.this); + mRecordsListView.setAdapter(mCgmsRecordsAdapter); + } + mCgmsRecordsAdapter.addItem(cgmsRecord); + mCgmsRecordsAdapter.notifyDataSetChanged(); + + } else if (CGMService.BROADCAST_DATA_SET_CHANGED.equals(action)) { + // Update GUI + clearRecords(); + } else if (CGMService.OPERATION_STARTED.equals(action)) { + // Update GUI + setOperationInProgress(true); + } else if (CGMService.OPERATION_COMPLETED.equals(action)) { + // Update GUI + setOperationInProgress(false); + } else if (CGMService.OPERATION_SUPPORTED.equals(action)) { + // Update GUI + setOperationInProgress(false); + } else if (CGMService.OPERATION_NOT_SUPPORTED.equals(action)) { + // Update GUI + setOperationInProgress(false); + } else if (CGMService.OPERATION_ABORTED.equals(action)) { + // Update GUI + setOperationInProgress(false); + } else if (CGMService.OPERATION_FAILED.equals(action)) { + // Update GUI + setOperationInProgress(false); + showToast(R.string.gls_operation_failed); + } + } + }; + + private static IntentFilter makeIntentFilter() { + final IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(CGMService.BROADCAST_CGMS_VALUES); + intentFilter.addAction(CGMService.BROADCAST_DATA_SET_CHANGED); + intentFilter.addAction(CGMService.OPERATION_STARTED); + intentFilter.addAction(CGMService.OPERATION_COMPLETED); + intentFilter.addAction(CGMService.OPERATION_SUPPORTED); + intentFilter.addAction(CGMService.OPERATION_NOT_SUPPORTED); + intentFilter.addAction(CGMService.OPERATION_ABORTED); + intentFilter.addAction(CGMService.OPERATION_FAILED); + return intentFilter; + } +} diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMSManager.java b/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMSManager.java new file mode 100644 index 00000000..e1f1c863 --- /dev/null +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMSManager.java @@ -0,0 +1,383 @@ +/* + * Copyright (c) 2016, Nordic Semiconductor + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package no.nordicsemi.android.nrftoolbox.cgms; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattService; +import android.content.Context; +import android.nfc.Tag; +import android.util.Log; +import android.util.SparseArray; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.LinkedList; +import java.util.Queue; +import java.util.UUID; + +import no.nordicsemi.android.log.Logger; +import no.nordicsemi.android.nrftoolbox.parser.HeartRateMeasurementParser; +import no.nordicsemi.android.nrftoolbox.parser.RecordAccessControlPointParser; +import no.nordicsemi.android.nrftoolbox.profile.BleManager; +import no.nordicsemi.android.nrftoolbox.utility.DebugLogger; + +/** + * Created by rora on 10.05.2016. + */ +public class CGMSManager extends BleManager { + private static final String TAG = "CGMSManager"; + + /** Cycling Speed and Cadence service UUID */ + public final static UUID CGMS_UUID = UUID.fromString("0000181F-0000-1000-8000-00805f9b34fb"); + private static final UUID CGM_MEASUREMENT_UUID = UUID.fromString("00002AA7-0000-1000-8000-00805f9b34fb"); + private static final UUID CGM_OPS_CONTROL_POINT_UUID = UUID.fromString("00002AAC-0000-1000-8000-00805f9b34fb"); + /** Record Access Control Point characteristic UUID */ + private final static UUID RACP_UUID = UUID.fromString("00002A52-0000-1000-8000-00805f9b34fb"); + + private final static int OP_CODE_REPORT_STORED_RECORDS = 1; + private final static int OP_CODE_DELETE_STORED_RECORDS = 2; + private final static int OP_CODE_ABORT_OPERATION = 3; + private final static int OP_CODE_REPORT_NUMBER_OF_RECORDS = 4; + private final static int OP_CODE_NUMBER_OF_STORED_RECORDS_RESPONSE = 5; + private final static int OP_CODE_RESPONSE_CODE = 6; + + private final static int OPERATOR_NULL = 0; + private final static int OPERATOR_ALL_RECORDS = 1; + private final static int OPERATOR_LESS_THEN_OR_EQUAL = 2; + private final static int OPERATOR_GREATER_THEN_OR_EQUAL = 3; + private final static int OPERATOR_WITHING_RANGE = 4; + private final static int OPERATOR_FIRST_RECORD = 5; + private final static int OPERATOR_LAST_RECORD = 6; + + /** + * The filter type is used for range operators ({@link #OPERATOR_LESS_THEN_OR_EQUAL}, {@link #OPERATOR_GREATER_THEN_OR_EQUAL}, {@link #OPERATOR_WITHING_RANGE}.
+ * The syntax of the operand is: [Filter Type][Minimum][Maximum].
+ * This filter selects the records by the sequence number. + */ + private final static int FILTER_TYPE_SEQUENCE_NUMBER = 1; + /** + * The filter type is used for range operators ({@link #OPERATOR_LESS_THEN_OR_EQUAL}, {@link #OPERATOR_GREATER_THEN_OR_EQUAL}, {@link #OPERATOR_WITHING_RANGE}.
+ * The syntax of the operand is: [Filter Type][Minimum][Maximum].
+ * This filter selects the records by the user facing time (base time + offset time). + */ + private final static int FILTER_TYPE_USER_FACING_TIME = 2; + private final static int RESPONSE_SUCCESS = 1; + private final static int RESPONSE_OP_CODE_NOT_SUPPORTED = 2; + private final static int RESPONSE_INVALID_OPERATOR = 3; + private final static int RESPONSE_OPERATOR_NOT_SUPPORTED = 4; + private final static int RESPONSE_INVALID_OPERAND = 5; + private final static int RESPONSE_NO_RECORDS_FOUND = 6; + private final static int RESPONSE_ABORT_UNSUCCESSFUL = 7; + private final static int RESPONSE_PROCEDURE_NOT_COMPLETED = 8; + private final static int RESPONSE_OPERAND_NOT_SUPPORTED = 9; + + private final static SimpleDateFormat mTimeFormat= new SimpleDateFormat("dd MM YYYY HH:mm:ss"); + + private BluetoothGattCharacteristic mCGMMEasurementCharacteristic; + private BluetoothGattCharacteristic mCGMOpsControlPointCharacteristic; + private BluetoothGattCharacteristic mRecordAccessControlPointCharacteristic; + + private static CGMSManager managerInstance = null; + private SparseArray mRecords = new SparseArray<>(); + private boolean mAbort; + private long mSessionStartTime; + + /** + * singleton implementation of HRSManager class + */ + public static synchronized CGMSManager getInstance(final Context context) { + if (managerInstance == null) { + managerInstance = new CGMSManager(context); + } + return managerInstance; + } + public CGMSManager(Context context) { + super(context); + } + + @Override + protected BleManagerGattCallback getGattCallback() { + return mGattCallback; + } + + /** + * BluetoothGatt callbacks for connection/disconnection, service discovery, receiving notification, etc + */ + private final BleManagerGattCallback mGattCallback = new BleManagerGattCallback() { + + @Override + protected Queue initGatt(final BluetoothGatt gatt) { + final LinkedList requests = new LinkedList<>(); + requests.push(Request.newEnableNotificationsRequest(mCGMMEasurementCharacteristic)); + if (mCGMOpsControlPointCharacteristic != null) { + mSessionStartTime = System.currentTimeMillis(); + requests.push(Request.newWriteRequest(mCGMOpsControlPointCharacteristic, new byte[]{26} /*start session value*/)); + } + requests.push(Request.newEnableIndicationsRequest(mRecordAccessControlPointCharacteristic)); + return requests; + } + + @Override + protected boolean isRequiredServiceSupported(final BluetoothGatt gatt) { + final BluetoothGattService service = gatt.getService(CGMS_UUID); + if (service != null) { + mCGMMEasurementCharacteristic = service.getCharacteristic(CGM_MEASUREMENT_UUID); + mCGMOpsControlPointCharacteristic = service.getCharacteristic(CGM_OPS_CONTROL_POINT_UUID); + mRecordAccessControlPointCharacteristic = service.getCharacteristic(RACP_UUID); + } + return mCGMMEasurementCharacteristic != null && mCGMOpsControlPointCharacteristic != null && mRecordAccessControlPointCharacteristic != null; + } + + @Override + protected boolean isOptionalServiceSupported(final BluetoothGatt gatt) { + final BluetoothGattService service = gatt.getService(CGMS_UUID); + if (service != null) { + mCGMOpsControlPointCharacteristic = service.getCharacteristic(CGM_OPS_CONTROL_POINT_UUID); + } + return mCGMOpsControlPointCharacteristic != null; + } + + @Override + public void onCharacteristicRead(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) { + } + + @Override + protected void onDeviceDisconnected() { + mCGMOpsControlPointCharacteristic = null; + mCGMMEasurementCharacteristic = null; + //mRecordAccessControlPointCharacteristic = null; + } + + @Override + public void onCharacteristicNotified(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) { + final UUID uuid = characteristic.getUuid(); + if(CGM_MEASUREMENT_UUID.equals(uuid)) { + if (mLogSession != null) + Logger.a(mLogSession, HeartRateMeasurementParser.parse(characteristic)); + byte [] data = characteristic.getValue(); + int cgmSize = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, 0); + float cgmValue; + int timeOffset; + if (cgmSize > 0) { + cgmValue = characteristic.getFloatValue(BluetoothGattCharacteristic.FORMAT_SFLOAT, 2); + timeOffset = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, 4); + Date date = new Date(mSessionStartTime + (timeOffset * 3600)); + final String timeStamp = mTimeFormat.format(date); + //This will send callback to CGMSActivity when new concentration value is received from CGMS device + mCallbacks.onCGMValueReceived(cgmValue, timeStamp); + } + } else if (CGM_OPS_CONTROL_POINT_UUID.equals(uuid)){ + Log.v(TAG, "CGM Ops control"); + } else if (RACP_UUID.equals(uuid)){ + Log.v(TAG, "RACP Ops control"); + } + } + + @Override + protected void onCharacteristicIndicated(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) { + if (mLogSession != null) + Logger.a(mLogSession, RecordAccessControlPointParser.parse(characteristic)); + + // Record Access Control Point characteristic + int offset = 0; + final int opCode = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, offset); + offset += 2; // skip the operator + + if (opCode == OP_CODE_NUMBER_OF_STORED_RECORDS_RESPONSE) { + // We've obtained the number of all records + final int number = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, offset); + + mCallbacks.onNumberOfRecordsRequested(number); + + // Request the records + if (number > 0) { + final BluetoothGattCharacteristic racpCharacteristic = mRecordAccessControlPointCharacteristic; + setOpCode(racpCharacteristic, OP_CODE_REPORT_STORED_RECORDS, OPERATOR_ALL_RECORDS); + writeCharacteristic(racpCharacteristic); + } else { + mCallbacks.onOperationCompleted(); + } + } else if (opCode == OP_CODE_RESPONSE_CODE) { + final int requestedOpCode = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, offset); + final int responseCode = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, offset + 1); + DebugLogger.d(TAG, "Response result for: " + requestedOpCode + " is: " + responseCode); + + switch (responseCode) { + case RESPONSE_SUCCESS: + if (!mAbort) + mCallbacks.onOperationCompleted(); + else + mCallbacks.onOperationAborted(); + break; + case RESPONSE_NO_RECORDS_FOUND: + mCallbacks.onOperationCompleted(); + break; + case RESPONSE_OP_CODE_NOT_SUPPORTED: + mCallbacks.onOperationNotSupported(); + break; + case RESPONSE_PROCEDURE_NOT_COMPLETED: + case RESPONSE_ABORT_UNSUCCESSFUL: + default: + mCallbacks.onOperationFailed(); + break; + } + mAbort = false; + } + } + + }; + + /** + * Writes given operation parameters to the characteristic + * + * @param characteristic + * the characteristic to write. This must be the Record Access Control Point characteristic + * @param opCode + * the operation code + * @param operator + * the operator (see {@link #OPERATOR_NULL} and others + * @param params + * optional parameters (one for >=, <=, two for the range, none for other operators) + */ + private void setOpCode(final BluetoothGattCharacteristic characteristic, final int opCode, final int operator, final Integer... params) { + final int size = 2 + ((params.length > 0) ? 1 : 0) + params.length * 2; // 1 byte for opCode, 1 for operator, 1 for filter type (if parameters exists) and 2 for each parameter + characteristic.setValue(new byte[size]); + + // write the operation code + int offset = 0; + characteristic.setValue(opCode, BluetoothGattCharacteristic.FORMAT_UINT8, offset); + offset += 1; + + // write the operator. This is always present but may be equal to OPERATOR_NULL + characteristic.setValue(operator, BluetoothGattCharacteristic.FORMAT_UINT8, offset); + offset += 1; + + // if parameters exists, append them. Parameters should be sorted from minimum to maximum. Currently only one or two params are allowed + if (params.length > 0) { + // our implementation use only sequence number as a filer type + characteristic.setValue(FILTER_TYPE_SEQUENCE_NUMBER, BluetoothGattCharacteristic.FORMAT_UINT8, offset); + offset += 1; + + for (final Integer i : params) { + characteristic.setValue(i, BluetoothGattCharacteristic.FORMAT_UINT16, offset); + offset += 2; + } + } + } + + /** + * Sends the request to obtain the last (most recent) record from glucose device. The data will be returned to Glucose Measurement characteristic as a notification followed by Record Access + * Control Point indication with status code ({@link #RESPONSE_SUCCESS} or other in case of error. + */ + public void getLastRecord() { + if (mRecordAccessControlPointCharacteristic == null) + return; + + clear(); + mCallbacks.onOperationStarted(); + + final BluetoothGattCharacteristic characteristic = mRecordAccessControlPointCharacteristic; + setOpCode(characteristic, OP_CODE_REPORT_STORED_RECORDS, OPERATOR_LAST_RECORD); + writeCharacteristic(characteristic); + } + + /** + * Returns all records as a sparse array where sequence number is the key. + * + * @return the records list + */ + public SparseArray getRecords() { + return mRecords; + } + + /** + * Clears the records list locally + */ + public void clear() { + mRecords.clear(); + mCallbacks.onDatasetChanged(); + } + + /** + * Sends abort operation signal to the device + */ + public void abort() { + if (mRecordAccessControlPointCharacteristic == null) + return; + + mAbort = true; + final BluetoothGattCharacteristic characteristic = mRecordAccessControlPointCharacteristic; + setOpCode(characteristic, OP_CODE_ABORT_OPERATION, OPERATOR_NULL); + writeCharacteristic(characteristic); + } + + /** + * Sends the request to obtain the first (oldest) record from glucose device. The data will be returned to Glucose Measurement characteristic as a notification followed by Record Access Control + * Point indication with status code ({@link #RESPONSE_SUCCESS} or other in case of error. + */ + public void getFirstRecord() { + if (mRecordAccessControlPointCharacteristic == null) + return; + + clear(); + mCallbacks.onOperationStarted(); + + final BluetoothGattCharacteristic characteristic = mRecordAccessControlPointCharacteristic; + setOpCode(characteristic, OP_CODE_REPORT_STORED_RECORDS, OPERATOR_FIRST_RECORD); + final LinkedList requests = new LinkedList<>(); + writeCharacteristic(characteristic); + } + + /** + * Sends the request to obtain all records from glucose device. Initially we want to notify him/her about the number of the records so the {@link #OP_CODE_REPORT_NUMBER_OF_RECORDS} is send. The + * data will be returned to Glucose Measurement characteristic as a notification followed by Record Access Control Point indication with status code ({@link #RESPONSE_SUCCESS} or other in case of + * error. + */ + public void getAllRecords() { + if (mRecordAccessControlPointCharacteristic == null) + return; + + clear(); + mCallbacks.onOperationStarted(); + + final BluetoothGattCharacteristic characteristic = mRecordAccessControlPointCharacteristic; + setOpCode(characteristic, OP_CODE_REPORT_NUMBER_OF_RECORDS, OPERATOR_ALL_RECORDS); + writeCharacteristic(characteristic); + } + + public void deleteAllRecords() { + if (mRecordAccessControlPointCharacteristic == null) + return; + + clear(); + mCallbacks.onOperationStarted(); + + final BluetoothGattCharacteristic characteristic = mRecordAccessControlPointCharacteristic; + setOpCode(characteristic, OP_CODE_DELETE_STORED_RECORDS, OPERATOR_ALL_RECORDS); + writeCharacteristic(characteristic); + } + +} + diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMSManagerCallbacks.java b/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMSManagerCallbacks.java new file mode 100644 index 00000000..040f7415 --- /dev/null +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMSManagerCallbacks.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2016, Nordic Semiconductor + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package no.nordicsemi.android.nrftoolbox.cgms; + +import no.nordicsemi.android.nrftoolbox.profile.BleManagerCallbacks; + +/** + * Created by rora on 10.05.2016. + */ +public interface CGMSManagerCallbacks extends BleManagerCallbacks { + /** + * Called when new CGM value has been obtained from the sensor + * + * @param value + * the new value + */ + public void onCGMValueReceived(float value, String timeStamp); + + + public void onOperationStarted(); + + public void onOperationCompleted(); + + public void onOperationFailed(); + + public void onOperationAborted(); + + public void onOperationNotSupported(); + + public void onDatasetChanged(); + + public void onNumberOfRecordsRequested(final int value); + +} diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMSRecord.java b/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMSRecord.java new file mode 100644 index 00000000..3568a9f1 --- /dev/null +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMSRecord.java @@ -0,0 +1,56 @@ +package no.nordicsemi.android.nrftoolbox.cgms; + +import android.os.Parcel; +import android.os.Parcelable; + +import java.util.Calendar; + +/** + * Created by rora on 02.09.2016. + */ +public class CGMSRecord implements Parcelable{ + + /** Record sequence number */ + protected int sequenceNumber; + /** The base time of the measurement */ + protected Calendar time; + /** Time offset of the record */ + protected String timeStamp; + protected float reading; + + protected CGMSRecord(float cgmsValue, String timeStamp) { + sequenceNumber = 0; + this.timeStamp = timeStamp; + this.reading = cgmsValue; + } + + protected CGMSRecord(Parcel in) { + sequenceNumber = in.readInt(); + timeStamp = in.readString(); + reading = in.readFloat(); + } + + public static final Creator CREATOR = new Creator() { + @Override + public CGMSRecord createFromParcel(Parcel in) { + return new CGMSRecord(in); + } + + @Override + public CGMSRecord[] newArray(int size) { + return new CGMSRecord[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int i) { + parcel.writeInt(sequenceNumber); + parcel.writeString(timeStamp); + parcel.writeFloat(reading); + } +} diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMSRecordsAdapter.java b/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMSRecordsAdapter.java new file mode 100644 index 00000000..e773bb82 --- /dev/null +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMSRecordsAdapter.java @@ -0,0 +1,87 @@ +package no.nordicsemi.android.nrftoolbox.cgms; + +import android.content.Context; +import android.util.SparseArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.TextView; + +import no.nordicsemi.android.nrftoolbox.R; + +/** + * Created by rora on 02.09.2016. + */ +public class CGMSRecordsAdapter extends BaseAdapter { + + private SparseArray mRecords; + private LayoutInflater mInflator; + private Context context; + + public CGMSRecordsAdapter(Context context) { + super(); + mRecords = new SparseArray<>(); + this.context = context; + mInflator = (LayoutInflater) this.context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + } + + @Override + public int getCount() { + return mRecords.size(); + } + + @Override + public Object getItem(int i) { + return null; + } + + @Override + public long getItemId(int i) { + return i; + } + + @Override + public View getView(final int position, View convertView, ViewGroup parent) { + + ViewHolder viewHolder; + if (convertView == null) { + convertView = mInflator.inflate(R.layout.activity_feature_cgms_item, null); + viewHolder = new ViewHolder(); + viewHolder.concentration = (TextView) convertView.findViewById(R.id.cgms_concentration); + viewHolder.time = (TextView) convertView.findViewById(R.id.time); + viewHolder.details = (TextView) convertView.findViewById(R.id.details); + convertView.setTag(viewHolder); + } else { + viewHolder = (ViewHolder) convertView.getTag(); + } + + final CGMSRecord cgmsRecord = mRecords.get(position); + final String concentration = String.valueOf(cgmsRecord.reading); + + if (concentration != null && concentration.length() > 0) { + viewHolder.concentration.setText(concentration /*+ " " + context.getString(R.string.cgms_value_unit*/); + viewHolder.time.setText(cgmsRecord.timeStamp); + } + + return convertView; + } + + public void addItem(CGMSRecord record) { + mRecords.put(mRecords.size(), record); + } + + public SparseArray getValues() { + return mRecords; + } + + public void clear() { + mRecords.clear(); + } + + static class ViewHolder { + TextView time; + TextView details; + TextView concentration; + } +} diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMService.java b/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMService.java new file mode 100644 index 00000000..3e91712d --- /dev/null +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/cgms/CGMService.java @@ -0,0 +1,267 @@ +package no.nordicsemi.android.nrftoolbox.cgms; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.support.v4.content.LocalBroadcastManager; +import android.support.v7.app.NotificationCompat; +import android.util.SparseArray; + +import no.nordicsemi.android.log.Logger; +import no.nordicsemi.android.nrftoolbox.FeaturesActivity; +import no.nordicsemi.android.nrftoolbox.R; +import no.nordicsemi.android.nrftoolbox.profile.BleManager; +import no.nordicsemi.android.nrftoolbox.profile.BleProfileService; + +/** + * Created by rora on 05.09.2016. + */ +public class CGMService extends BleProfileService implements CGMSManagerCallbacks { + private static final String ACTION_DISCONNECT = "no.nordicsemi.android.nrftoolbox.cgms.ACTION_DISCONNECT"; + public static final String BROADCAST_CGMS_VALUES = "no.nordicsemi.android.nrftoolbox.cgms.BROADCAST_CGMS_VALUES"; + public static final String OPERATION_STARTED = "no.nordicsemi.android.nrftoolbox.cgms.OPERATION_STARTED"; + public static final String OPERATION_COMPLETED = "no.nordicsemi.android.nrftoolbox.cgms.OPERATION_COMPLETED"; + public static final String OPERATION_SUPPORTED = "no.nordicsemi.android.nrftoolbox.cgms.OPERATION_SUPPORTED"; + public static final String OPERATION_NOT_SUPPORTED = "no.nordicsemi.android.nrftoolbox.cgms.OPERATION_NOT_SUPPORTED"; + public static final String OPERATION_FAILED = "no.nordicsemi.android.nrftoolbox.cgms.OPERATION_FAILED"; + public static final String OPERATION_ABORTED = "no.nordicsemi.android.nrftoolbox.cgms.OPERATION_ABORTED"; + public static final String EXTRA_CGMS_RECORD = "no.nordicsemi.android.nrftoolbox.cgms.EXTRA_CGMS_RECORD"; + public static final String BROADCAST_DATA_SET_CHANGED = "no.nordicsemi.android.nrftoolbox.cgms.BROADCAST_DATA_SET_CHANGED"; + public static final String EXTRA_DATA = "no.nordicsemi.android.nrftoolbox.cgms.EXTRA_DATA"; + + private final static int NOTIFICATION_ID = 229; + private final static int OPEN_ACTIVITY_REQ = 0; + private final static int DISCONNECT_REQ = 1; + + private CGMSManager mManager; + private final LocalBinder mBinder = new CGMSBinder(); + private SparseArray mRecords = new SparseArray<>(); + + /** + * This local binder is an interface for the bonded activity to operate with the RSC sensor + */ + + public class CGMSBinder extends LocalBinder { + public SparseArray getCgmsRecords() { + return mRecords; + } + + /** + * Sends the request to obtain the last (most recent) record from glucose device. The data will be returned to Glucose Measurement characteristic as a notification followed by Record Access + * Control Point indication with status code ({@link CGMSManager#RESPONSE_SUCCESS} or other in case of error. + */ + public void getLastRecord() { + clear(); + if(mManager != null) + mManager.getLastRecord(); + } + + /** + * Returns all records as a sparse array where sequence number is the key. + * + * @return the records list + */ + public SparseArray getRecords() { + return mRecords; + } + + /** + * Clears the records list locally + */ + public void clear() { + mRecords.clear(); + } + + /** + * Sends abort operation signal to the device + */ + public void abort() { + + if(mManager != null) + mManager.abort(); + } + + /** + * Sends the request to obtain the first (oldest) record from glucose device. The data will be returned to Glucose Measurement characteristic as a notification followed by Record Access Control + * Point indication with status code ({@link CGMSManager# RESPONSE_SUCCESS} or other in case of error. + */ + public void getFirstRecord() { + if(mManager != null) + mManager.getFirstRecord(); + } + + /** + * Sends the request to obtain all records from glucose device. Initially we want to notify him/her about the number of the records so the {@link CGMSManager#OP_CODE_REPORT_NUMBER_OF_RECORDS} is send. The + * data will be returned to Glucose Measurement characteristic as a notification followed by Record Access Control Point indication with status code ({@link CGMSManager#RESPONSE_SUCCESS} or other in case of + * error. + */ + public void getAllRecords() { + clear(); + if(mManager != null) + mManager.getAllRecords(); + } + + public void deleteAllRecords() { + + if(mManager != null) + mManager.deleteAllRecords(); + } + } + + @Override + protected LocalBinder getBinder() { + return mBinder; + } + + @Override + protected BleManager initializeManager() { + return mManager = new CGMSManager(this); + } + + + @Override + public void onCreate() { + super.onCreate(); + final IntentFilter filter = new IntentFilter(); + filter.addAction(ACTION_DISCONNECT); + registerReceiver(mDisconnectActionBroadcastReceiver, filter); + } + + @Override + public void onDestroy() { + // when user has disconnected from the sensor, we have to cancel the notification that we've created some milliseconds before using unbindService + cancelNotification(); + unregisterReceiver(mDisconnectActionBroadcastReceiver); + + super.onDestroy(); + } + + @Override + protected void onRebind() { + // when the activity rebinds to the service, remove the notification + cancelNotification(); + } + + @Override + protected void onUnbind() { + // when the activity closes we need to show the notification that user is connected to the sensor + createNotification(R.string.csc_notification_connected_message, 0); + } + + @Override + protected void onServiceStarted() { + // logger is now available. Assign it to the manager + mManager.setLogger(getLogSession()); + } + + /** + * Creates the notification + * + * @param messageResId + * the message resource id. The message must have one String parameter,
+ * f.e. <string name="name">%s is connected</string> + * @param defaults + * signals that will be used to notify the user + */ + private void createNotification(final int messageResId, final int defaults) { + final Intent parentIntent = new Intent(this, FeaturesActivity.class); + parentIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + final Intent targetIntent = new Intent(this, CGMSActivity.class); + + final Intent disconnect = new Intent(ACTION_DISCONNECT); + final PendingIntent disconnectAction = PendingIntent.getBroadcast(this, DISCONNECT_REQ, disconnect, PendingIntent.FLAG_UPDATE_CURRENT); + + // both activities above have launchMode="singleTask" in the AndroidManifest.xml file, so if the task is already running, it will be resumed + final PendingIntent pendingIntent = PendingIntent.getActivities(this, OPEN_ACTIVITY_REQ, new Intent[] { parentIntent, targetIntent }, PendingIntent.FLAG_UPDATE_CURRENT); + final NotificationCompat.Builder builder = new NotificationCompat.Builder(this); + builder.setContentIntent(pendingIntent); + builder.setContentTitle(getString(R.string.app_name)).setContentText(getString(messageResId, getDeviceName())); + builder.setSmallIcon(R.drawable.ic_stat_notify_cgms); + builder.setShowWhen(defaults != 0).setDefaults(defaults).setAutoCancel(true).setOngoing(true); + builder.addAction(new NotificationCompat.Action(R.drawable.ic_action_bluetooth, getString(R.string.csc_notification_action_disconnect), disconnectAction)); + + final Notification notification = builder.build(); + final NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + nm.notify(NOTIFICATION_ID, notification); + } + + /** + * Cancels the existing notification. If there is no active notification this method does nothing + */ + private void cancelNotification() { + final NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + nm.cancel(NOTIFICATION_ID); + } + + /** + * This broadcast receiver listens for {@link #ACTION_DISCONNECT} that may be fired by pressing Disconnect action button on the notification. + */ + private final BroadcastReceiver mDisconnectActionBroadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(final Context context, final Intent intent) { + Logger.i(getLogSession(), "[Notification] Disconnect action pressed"); + if (isConnected()) + getBinder().disconnect(); + else + stopSelf(); + } + }; + + @Override + public void onCGMValueReceived(float value, String timeStamp) { + final Intent broadcast = new Intent(BROADCAST_CGMS_VALUES); + CGMSRecord cgmsRecord = new CGMSRecord(value, timeStamp); + broadcast.putExtra(EXTRA_CGMS_RECORD, cgmsRecord); + LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast); + mRecords.put(mRecords.size(), cgmsRecord); + } + + @Override + public void onOperationStarted() { + final Intent broadcast = new Intent(OPERATION_STARTED); + broadcast.putExtra(EXTRA_DATA, true); + LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast); + } + + @Override + public void onOperationCompleted() { + final Intent broadcast = new Intent(OPERATION_COMPLETED); + broadcast.putExtra(EXTRA_DATA, true); + LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast); + } + + @Override + public void onOperationFailed() { + final Intent broadcast = new Intent(OPERATION_FAILED); + broadcast.putExtra(EXTRA_DATA, true); + LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast); + } + + @Override + public void onOperationAborted() { + final Intent broadcast = new Intent(OPERATION_ABORTED); + broadcast.putExtra(EXTRA_DATA, true); + LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast); + } + + @Override + public void onOperationNotSupported() { + final Intent broadcast = new Intent(OPERATION_NOT_SUPPORTED); + broadcast.putExtra(EXTRA_DATA, false); + LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast); + } + + @Override + public void onDatasetChanged() { + + } + + @Override + public void onNumberOfRecordsRequested(int value) { + showToast(getString(R.string.gls_progress, value)); + } +} diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/profile/BleProfileActivity.java b/app/src/main/java/no/nordicsemi/android/nrftoolbox/profile/BleProfileActivity.java index aaec5229..bdb0d8ae 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/profile/BleProfileActivity.java +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/profile/BleProfileActivity.java @@ -265,6 +265,7 @@ public abstract class BleProfileActivity extends AppCompatActivity implements Bl mConnectButton.setText(R.string.action_connect); mDeviceNameView.setText(getDefaultDeviceName()); mBatteryLevelView.setText(R.string.not_available); + mConnectButton.setEnabled(true); } }); } diff --git a/app/src/main/res/drawable-hdpi/ic_cgms_feature.png b/app/src/main/res/drawable-hdpi/ic_cgms_feature.png new file mode 100644 index 00000000..6e05b926 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_cgms_feature.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_stat_notify_cgms.png b/app/src/main/res/drawable-hdpi/ic_stat_notify_cgms.png new file mode 100644 index 00000000..2b783a8c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_notify_cgms.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_cgms_feature.png b/app/src/main/res/drawable-xhdpi/ic_cgms_feature.png new file mode 100644 index 00000000..c18519d2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_cgms_feature.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_hts_feature_black.png b/app/src/main/res/drawable-xhdpi/ic_hts_feature_black.png new file mode 100644 index 00000000..e4351f69 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_hts_feature_black.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_notify_cgms.png b/app/src/main/res/drawable-xhdpi/ic_stat_notify_cgms.png new file mode 100644 index 00000000..4ff0268a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_notify_cgms.png differ diff --git a/app/src/main/res/layout-land/activity_feature_bpm.xml b/app/src/main/res/layout-land/activity_feature_bpm.xml index 6d6e34bf..55e980f9 100644 --- a/app/src/main/res/layout-land/activity_feature_bpm.xml +++ b/app/src/main/res/layout-land/activity_feature_bpm.xml @@ -24,7 +24,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" - tools:context=".BPMActivity"> + tools:context=".bpm.BPMActivity"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +