From aae1d40d54f5c4f11a52bb58a782e5cd1fcec448 Mon Sep 17 00:00:00 2001 From: Daniel Dakhno Date: Sat, 24 Aug 2024 00:41:19 +0200 Subject: [PATCH] Core: added first iteration of BLE intent API Core: added BLE GATT Client Core: fixed string comparisons Core: unified intent APIs Core: fixed notification and publication bugs Core: extracted BLE Intent API logic Core: introduced finer BLE API permissions Core: use device name when adding test device through DiscoveryActivity Core: avoid reporting same device state multiple times Core: read firmware version on GATT Client connect connect Core: use onSendConfiguration instead of direct subscription Core: I18N for GATT API settings Core: I18N for GATT API settings Core: only show BLE API settings for BLE devices Core: refactored intent handler Core: extracted ble API to own class Core: fixed unitialized BLE Api BLE Intent API: I18N BLE Intent API: refactoring BLE Intent API: added back legacy API BLE Intent API: removed new DEVICE_CHANGED and CONNECT endpoints BLE Intent API: removed redundant ble api setting --- .../activities/DebugActivity.java | 20 +- .../DeviceSettingsPreferenceConst.java | 5 + .../DeviceSpecificSettingsFragment.java | 11 + .../discovery/DiscoveryActivityV2.java | 7 +- .../gadgetbridge/model/DeviceType.java | 2 + .../service/DeviceCommunicationService.java | 29 ++- .../btle/AbstractBTLEDeviceSupport.java | 55 +++- .../service/btle/BleIntentApi.java | 241 ++++++++++++++++++ .../gatt_client/BleGattClientCoordinator.java | 51 ++++ .../gatt_client/BleGattClientSupport.java | 71 ++++++ .../gadgetbridge/util/StringUtils.java | 14 + app/src/main/res/values/strings.xml | 9 + .../main/res/xml/devicesettings_ble_api.xml | 27 ++ 13 files changed, 520 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleIntentApi.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gatt_client/BleGattClientCoordinator.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gatt_client/BleGattClientSupport.java create mode 100644 app/src/main/res/xml/devicesettings_ble_api.xml diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DebugActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DebugActivity.java index ca17c1f6d..d2d6e9e5d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DebugActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DebugActivity.java @@ -621,7 +621,7 @@ public class DebugActivity extends AbstractGBActivity { .setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { - createTestDevice(DebugActivity.this, selectedTestDeviceKey, selectedTestDeviceMAC); + createTestDevice(DebugActivity.this, selectedTestDeviceKey, selectedTestDeviceMAC, null); } }) .setNegativeButton(R.string.Cancel, new DialogInterface.OnClickListener() { @@ -1037,16 +1037,19 @@ public class DebugActivity extends AbstractGBActivity { spinner.setOnItemSelectedListener(new CustomOnDeviceSelectedListener()); } - public static void createTestDevice(Context context, long deviceKey, String deviceMac) { + public static void createTestDevice(Context context, long deviceKey, String deviceMac, String deviceName) { if (deviceKey == SELECT_DEVICE) { return; } DeviceType deviceType = DeviceType.values()[(int) deviceKey]; - String deviceName = deviceType.name(); - int deviceNameResource = deviceType.getDeviceCoordinator().getDeviceNameResource(); - if(deviceNameResource != 0){ - deviceName = context.getString(deviceNameResource); - } + if(deviceName == null) { + int deviceNameResource = deviceType.getDeviceCoordinator().getDeviceNameResource(); + if(deviceNameResource == 0){ + deviceName = deviceType.name(); + }else { + deviceName = context.getString(deviceNameResource); + } + }; try ( DBHandler db = GBApplication.acquireDB()) { DaoSession daoSession = db.getDaoSession(); @@ -1221,6 +1224,9 @@ public class DebugActivity extends AbstractGBActivity { TreeMap > sortedMap = new TreeMap<>(newMap); newMap = new LinkedHashMap<>(1); newMap.put(app.getString(R.string.widget_settings_select_device_title), new Pair(SELECT_DEVICE, R.drawable.ic_device_unknown)); + newMap.put(app.getString(R.string.devicetype_scannable), new Pair((long) DeviceType.SCANNABLE.ordinal(), R.drawable.ic_device_scannable)); + newMap.put(app.getString(R.string.devicetype_ble_gatt_client), new Pair((long) DeviceType.BLE_GATT_CLIENT.ordinal(), R.drawable.ic_device_scannable)); + newMap.putAll(sortedMap); return newMap; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java index 3528fd7da..d2c50cc49 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java @@ -526,4 +526,9 @@ public class DeviceSettingsPreferenceConst { public static final String PREF_CYCLING_SENSOR_PERSISTENCE_INTERVAL = "pref_cycling_persistence_interval"; public static final String PREF_CYCLING_SENSOR_WHEEL_DIAMETER = "pref_cycling_wheel_diameter"; + + public static final String PREFS_KEY_DEVICE_BLE_API_DEVICE_STATE = "prefs_device_ble_api_state"; + public static final String PREFS_KEY_DEVICE_BLE_API_DEVICE_READ_WRITE = "prefs_device_ble_api_characteristic_read_write"; + public static final String PREFS_KEY_DEVICE_BLE_API_DEVICE_NOTIFY = "prefs_device_ble_api_characteristic_notify"; + public static final String PREFS_KEY_DEVICE_BLE_API_PACKAGE = "prefs_device_ble_api_package"; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java index 9563039e5..aed535dc7 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java @@ -827,6 +827,11 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i addPreferenceHandlerFor(PREF_SPEAK_NOTIFICATIONS_FOCUS_EXCLUSIVE); + addPreferenceHandlerFor(PREFS_KEY_DEVICE_BLE_API_DEVICE_STATE); + addPreferenceHandlerFor(PREFS_KEY_DEVICE_BLE_API_DEVICE_READ_WRITE); + addPreferenceHandlerFor(PREFS_KEY_DEVICE_BLE_API_DEVICE_NOTIFY); + addPreferenceHandlerFor(PREFS_KEY_DEVICE_BLE_API_PACKAGE); + addPreferenceHandlerFor("lock"); String sleepTimeState = prefs.getString(PREF_SLEEP_TIME, PREF_DO_NOT_DISTURB_OFF); @@ -1307,6 +1312,12 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i DeviceSpecificSettingsScreen.DEVELOPER, R.xml.devicesettings_settings_third_party_apps ); + if(coordinator.getConnectionType().usesBluetoothLE()) { + deviceSpecificSettings.addRootScreen( + DeviceSpecificSettingsScreen.DEVELOPER, + R.xml.devicesettings_ble_api + ); + } } final DeviceSpecificSettingsCustomizer deviceSpecificSettingsCustomizer = coordinator.getDeviceSpecificSettingsCustomizer(device); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryActivityV2.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryActivityV2.java index 8733b1184..84010ff03 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryActivityV2.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryActivityV2.java @@ -748,7 +748,12 @@ public class DiscoveryActivityV2 extends AbstractGBActivity implements AdapterVi .setView(linearLayout) .setPositiveButton(R.string.ok, (dialog, which) -> { if (selectedUnsupportedDeviceKey != DebugActivity.SELECT_DEVICE) { - DebugActivity.createTestDevice(DiscoveryActivityV2.this, selectedUnsupportedDeviceKey, deviceCandidate.getMacAddress()); + DebugActivity.createTestDevice( + DiscoveryActivityV2.this, + selectedUnsupportedDeviceKey, + deviceCandidate.getMacAddress(), + deviceCandidate.getName() + ); finish(); } }) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java index 8621f31e3..8303a9e81 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java @@ -257,6 +257,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watchs1pro.XiaomiWatc import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watchs3.XiaomiWatchS3Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.xwatch.XWatchCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.zetime.ZeTimeCoordinator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.gatt_client.BleGattClientCoordinator; /** * For every supported device, a device type constant must exist. @@ -500,6 +501,7 @@ public enum DeviceType { COLMI_R06(ColmiR06Coordinator.class), SCANNABLE(ScannableDeviceCoordinator.class), CYCLING_SENSOR(CyclingSensorCoordinator.class), + BLE_GATT_CLIENT(BleGattClientCoordinator.class), TEST(TestDeviceCoordinator.class); private DeviceCoordinator coordinator; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java index a309d3ed4..472e075ba 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java @@ -101,7 +101,9 @@ import nodomain.freeyourgadget.gadgetbridge.model.NotificationType; import nodomain.freeyourgadget.gadgetbridge.model.Reminder; import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; import nodomain.freeyourgadget.gadgetbridge.model.WorldClock; +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLEScanService; +import nodomain.freeyourgadget.gadgetbridge.service.btle.BleIntentApi; import nodomain.freeyourgadget.gadgetbridge.service.receivers.AutoConnectIntervalReceiver; import nodomain.freeyourgadget.gadgetbridge.service.receivers.GBAutoFetchReceiver; import nodomain.freeyourgadget.gadgetbridge.util.EmojiConverter; @@ -289,16 +291,17 @@ public class DeviceCommunicationService extends Service implements SharedPrefere "com.spotify.music.playbackstatechanged" }; - private final String COMMAND_BLUETOOTH_CONNECT = "nodomain.freeyourgadget.gadgetbridge.BLUETOOTH_CONNECT"; - private final String ACTION_DEVICE_CONNECTED = "nodomain.freeyourgadget.gadgetbridge.BLUETOOTH_CONNECTED"; - private final String ACTION_DEVICE_SCANNED = "nodomain.freeyourgadget.gadgetbridge.BLUETOOTH_SCANNED"; private final int NOTIFICATIONS_CACHE_MAX = 10; // maximum amount of notifications to cache per device while disconnected private boolean allowBluetoothIntentApi = false; private boolean reconnectViaScan = GBPrefs.RECONNECT_SCAN_DEFAULT; + private final String API_LEGACY_COMMAND_BLUETOOTH_CONNECT = "nodomain.freeyourgadget.gadgetbridge.BLUETOOTH_CONNECT"; + private final String API_LEGACY_ACTION_DEVICE_CONNECTED = "nodomain.freeyourgadget.gadgetbridge.BLUETOOTH_CONNECTED"; + private final String API_LEGACY_ACTION_DEVICE_SCANNED = "nodomain.freeyourgadget.gadgetbridge.BLUETOOTH_SCANNED"; + private void sendDeviceAPIBroadcast(String address, String action){ if(!allowBluetoothIntentApi){ - GB.log("not sending API event due to settings", GB.INFO, null); + LOG.debug("not sending API event due to settings"); return; } Intent intent = new Intent(action); @@ -308,14 +311,14 @@ public class DeviceCommunicationService extends Service implements SharedPrefere } private void sendDeviceConnectedBroadcast(String address){ - sendDeviceAPIBroadcast(address, ACTION_DEVICE_CONNECTED); + sendDeviceAPIBroadcast(address, API_LEGACY_ACTION_DEVICE_CONNECTED); } BroadcastReceiver bluetoothCommandReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { switch (intent.getAction()){ - case COMMAND_BLUETOOTH_CONNECT: + case API_LEGACY_COMMAND_BLUETOOTH_CONNECT: if(!allowBluetoothIntentApi){ GB.log("Connection API not allowed in settings", GB.ERROR, null); return; @@ -333,6 +336,7 @@ public class DeviceCommunicationService extends Service implements SharedPrefere if(isDeviceConnected(address)){ GB.log(String.format("device %s already connected", address), GB.INFO, null); sendDeviceConnectedBroadcast(address); + return; } @@ -404,11 +408,10 @@ public class DeviceCommunicationService extends Service implements SharedPrefere GBDevice.DeviceUpdateSubject subject = (GBDevice.DeviceUpdateSubject) intent.getSerializableExtra(GBDevice.EXTRA_UPDATE_SUBJECT); if(subject == GBDevice.DeviceUpdateSubject.DEVICE_STATE && device.isInitialized()){ - LOG.debug("device state update reason"); sendDeviceConnectedBroadcast(device.getAddress()); sendCachedNotifications(device); - }else if(subject == GBDevice.DeviceUpdateSubject.CONNECTION_STATE && (device.getState() == GBDevice.State.SCANNED)){ - sendDeviceAPIBroadcast(device.getAddress(), ACTION_DEVICE_SCANNED); + }else if(subject == GBDevice.DeviceUpdateSubject.DEVICE_STATE && (device.getState() == GBDevice.State.SCANNED)){ + sendDeviceAPIBroadcast(device.getAddress(), API_LEGACY_ACTION_DEVICE_SCANNED); } }else if(BLEScanService.EVENT_DEVICE_FOUND.equals(action)){ String deviceAddress = intent.getStringExtra(BLEScanService.EXTRA_DEVICE_ADDRESS); @@ -449,14 +452,14 @@ public class DeviceCommunicationService extends Service implements SharedPrefere } target.setState(GBDevice.State.SCANNED); - target.sendDeviceUpdateIntent(DeviceCommunicationService.this, GBDevice.DeviceUpdateSubject.CONNECTION_STATE); + target.sendDeviceUpdateIntent(DeviceCommunicationService.this, GBDevice.DeviceUpdateSubject.DEVICE_STATE); new Handler().postDelayed(() -> { if(target.getState() != GBDevice.State.SCANNED){ return; } deviceLastScannedTimestamps.put(target.getAddress(), System.currentTimeMillis()); target.setState(GBDevice.State.WAITING_FOR_SCAN); - target.sendDeviceUpdateIntent(DeviceCommunicationService.this, GBDevice.DeviceUpdateSubject.CONNECTION_STATE); + target.sendDeviceUpdateIntent(DeviceCommunicationService.this, GBDevice.DeviceUpdateSubject.DEVICE_STATE); }, timeoutSeconds * 1000); return; } @@ -508,7 +511,7 @@ public class DeviceCommunicationService extends Service implements SharedPrefere ContextCompat.registerReceiver(this, mAutoConnectInvervalReceiver, new IntentFilter("GB_RECONNECT"), ContextCompat.RECEIVER_EXPORTED); IntentFilter bluetoothCommandFilter = new IntentFilter(); - bluetoothCommandFilter.addAction(COMMAND_BLUETOOTH_CONNECT); + bluetoothCommandFilter.addAction(API_LEGACY_COMMAND_BLUETOOTH_CONNECT); ContextCompat.registerReceiver(this, bluetoothCommandReceiver, bluetoothCommandFilter, ContextCompat.RECEIVER_EXPORTED); final IntentFilter deviceSettingsIntentFilter = new IntentFilter(); @@ -557,7 +560,7 @@ public class DeviceCommunicationService extends Service implements SharedPrefere } createDeviceStruct(device); device.setState(GBDevice.State.WAITING_FOR_SCAN); - device.sendDeviceUpdateIntent(this); + device.sendDeviceUpdateIntent(this, GBDevice.DeviceUpdateSubject.DEVICE_STATE); } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java index 9d22ec9ff..a0d5352f0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java @@ -17,11 +17,13 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.btle; +import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.BluetoothGattDescriptor; import android.bluetooth.BluetoothGattService; +import android.content.Context; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -66,6 +68,8 @@ public abstract class AbstractBTLEDeviceSupport extends AbstractDeviceSupport im public static final String BASE_UUID = "0000%s-0000-1000-8000-00805f9b34fb"; //this is common for all BTLE devices. see http://stackoverflow.com/questions/18699251/finding-out-android-bluetooth-le-gatt-profiles private final Object characteristicsMonitor = new Object(); + private BleIntentApi bleApi = null; + public AbstractBTLEDeviceSupport(Logger logger) { this.logger = logger; if (logger == null) { @@ -77,11 +81,15 @@ public abstract class AbstractBTLEDeviceSupport extends AbstractDeviceSupport im public boolean connect() { if (mQueue == null) { mQueue = new BtLEQueue(getBluetoothAdapter(), getDevice(), this, this, getContext(), mSupportedServerServices); + if(bleApi != null) { + bleApi.setQueue(mQueue); + } mQueue.setAutoReconnect(getAutoReconnect()); mQueue.setScanReconnect(getScanReconnect()); mQueue.setImplicitGattCallbackModify(getImplicitCallbackModify()); mQueue.setSendWriteRequestResponse(getSendWriteRequestResponse()); } + return mQueue.connect(); } @@ -91,6 +99,29 @@ public abstract class AbstractBTLEDeviceSupport extends AbstractDeviceSupport im } } + public BleIntentApi getBleApi() { + return bleApi; + } + + @Override + public void onSendConfiguration(String config) { + if(bleApi != null) { + bleApi.onSendConfiguration(config); + } + } + + @Override + public void setContext(GBDevice gbDevice, BluetoothAdapter btAdapter, Context context) { + super.setContext(gbDevice, btAdapter, context); + + if(BleIntentApi.isEnabled(gbDevice)) { + bleApi = new BleIntentApi(context, gbDevice); + bleApi.handleBLEApiPrefs(); + } + } + + + /** * Returns whether the gatt callback should be implicitly set to the one on the transaction, * even if it was not set directly on the transaction. If true, the gatt callback will always @@ -139,6 +170,10 @@ public abstract class AbstractBTLEDeviceSupport extends AbstractDeviceSupport im mQueue.dispose(); mQueue = null; } + + if(bleApi != null) { + bleApi.dispose(); + } } public TransactionBuilder createTransactionBuilder(String taskName) { @@ -271,6 +306,10 @@ public abstract class AbstractBTLEDeviceSupport extends AbstractDeviceSupport im Set supportedServices = getSupportedServices(); Map newCharacteristics = new HashMap<>(); for (BluetoothGattService service : discoveredGattServices) { + if(bleApi != null) { + bleApi.addService(service); + } + if (supportedServices.contains(service.getUuid())) { logger.debug("discovered supported service: {}: {}", BleNamesResolver.resolveServiceName(service.getUuid().toString()), service.getUuid()); List characteristics = service.getCharacteristics(); @@ -322,12 +361,22 @@ public abstract class AbstractBTLEDeviceSupport extends AbstractDeviceSupport im logger.warn("Services discovered, but device state is already " + getDevice().getState() + " for device: " + getDevice() + ", so ignoring"); return; } - initializeDevice(createTransactionBuilder("Initializing device")).queue(getQueue()); + TransactionBuilder builder = createTransactionBuilder("Initializing device"); + + if(bleApi != null) { + bleApi.initializeDevice(builder); + } + + initializeDevice(builder).queue(getQueue()); } @Override public boolean onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + if(bleApi != null) { + bleApi.onCharacteristicChanged(characteristic); + } + for (AbstractBleProfile profile : mSupportedProfiles) { if (profile.onCharacteristicRead(gatt, characteristic, status)) { return true; @@ -370,6 +419,10 @@ public abstract class AbstractBTLEDeviceSupport extends AbstractDeviceSupport im @Override public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + if(bleApi != null) { + bleApi.onCharacteristicChanged(characteristic); + } + for (AbstractBleProfile profile : mSupportedProfiles) { if (profile.onCharacteristicChanged(gatt, characteristic)) { return true; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleIntentApi.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleIntentApi.java new file mode 100644 index 000000000..29e7b9f6e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleIntentApi.java @@ -0,0 +1,241 @@ +package nodomain.freeyourgadget.gadgetbridge.service.btle; + +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREFS_KEY_DEVICE_BLE_API_DEVICE_NOTIFY; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREFS_KEY_DEVICE_BLE_API_DEVICE_READ_WRITE; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREFS_KEY_DEVICE_BLE_API_DEVICE_STATE; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREFS_KEY_DEVICE_BLE_API_PACKAGE; + +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattServer; +import android.bluetooth.BluetoothGattService; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.widget.Toast; + +import androidx.core.content.ContextCompat; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.UUID; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.util.GB; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; +import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; + +public class BleIntentApi { + private Context context; + GBDevice device; + BtLEQueue queue; + Logger logger; + + private boolean intentApiEnabledDeviceState = false; + private boolean intentApiEnabledReadWrite= false; + private boolean intentApiEnabledNotifications= false; + private String intentApiPackage = ""; + private boolean intentApiCharacteristicReceiverRegistered = false; + private boolean intentApiDeviceStateReceiverRegistered = false; + private String lastReportedState = null; + + private final HashMap characteristics = new HashMap<>(); + + public static final String BLE_API_COMMAND_READ = "nodomain.freeyourgadget.gadgetbridge.ble_api.commands.CHARACTERISTIC_READ"; + public static final String BLE_API_COMMAND_WRITE = "nodomain.freeyourgadget.gadgetbridge.ble_api.commands.CHARACTERISTIC_WRITE"; + public static final String BLE_API_EVENT_CHARACTERISTIC_CHANGED = "nodomain.freeyourgadget.gadgetbridge.ble_api.events.CHARACTERISTIC_CHANGED"; + + + BroadcastReceiver intentApiReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + + boolean isWrite = BLE_API_COMMAND_WRITE.equals(action); + + boolean isRead = BLE_API_COMMAND_READ.equals(action); + + if((!isWrite) && (!isRead)) { + return; + } + + if (!concernsThisDevice(intent)) { + return; + } + + if(!getDevice().getState().equalsOrHigherThan(GBDevice.State.INITIALIZED)) { + logger.error(String.format("BLE API: Device %s not initialized.", getDevice())); + return; + } + + String uuid = intent.getStringExtra("EXTRA_CHARACTERISTIC_UUID"); + if (StringUtils.isNullOrEmpty(uuid)) { + logger.error("BLE API: missing EXTRA_CHARACTERISTIC_UUID"); + return; + } + + String hexData = intent.getStringExtra("EXTRA_PAYLOAD"); + if (hexData == null) { + logger.error("BLE API: missing EXTRA_PAYLOAD"); + return; + } + + BluetoothGattCharacteristic characteristic = characteristics.get(uuid); + + if(characteristic == null) { + logger.error("Characteristic {} not found", uuid); + return; + } + + if(isWrite) { + new TransactionBuilder("BLE API write") + .write(characteristic, StringUtils.hexToBytes(hexData)) + .queue(getQueue()); + return; + } + + if(isRead) { + new TransactionBuilder("BLE API read") + .read(characteristic) + .queue(getQueue()); + return; + } + } + }; + + public static boolean isEnabled(GBDevice device) { + Prefs devicePrefs = GBApplication.getDevicePrefs(device.getAddress()); + + boolean intentApiEnabledReadWrite = devicePrefs.getBoolean(PREFS_KEY_DEVICE_BLE_API_DEVICE_READ_WRITE, false); + boolean intentApiEnabledNotifications = devicePrefs.getBoolean(PREFS_KEY_DEVICE_BLE_API_DEVICE_NOTIFY, false); + boolean intentApiEnabledDeviceState = devicePrefs.getBoolean(PREFS_KEY_DEVICE_BLE_API_DEVICE_STATE, false); + + return intentApiEnabledReadWrite | intentApiEnabledNotifications | intentApiEnabledDeviceState; + } + + public void onCharacteristicChanged(BluetoothGattCharacteristic characteristic) { + if(!intentApiEnabledNotifications) { + return; + } + Intent intent = getBleApiIntent(BLE_API_EVENT_CHARACTERISTIC_CHANGED); + if(!StringUtils.isNullOrEmpty(intentApiPackage)) { + intent.setPackage(intentApiPackage); + } + intent.putExtra("EXTRA_CHARACTERISTIC", characteristic.getUuid().toString()); + intent.putExtra("EXTRA_PAYLOAD", StringUtils.bytesToHex(characteristic.getValue())); + + getContext().sendBroadcast(intent); + } + + public void initializeDevice(TransactionBuilder builder) { + if(intentApiEnabledNotifications) { + for (BluetoothGattCharacteristic characteristic : characteristics.values()) { + builder.notify(characteristic, true); + } + } + } + + public void dispose() { + registerBleApiCharacteristicReceivers(false); + } + + public void onSendConfiguration(String config) { + if(StringUtils.isNullOrEmpty(config)) { + return; + } + if(config.startsWith("prefs_device_ble_api_")) { + // could subscribe here, but there is more setup to do than that... + // handleBLEApiPrefs(); + GB.toast( + getContext().getString(R.string.toast_setting_requires_reconnect), + Toast.LENGTH_SHORT, + GB.INFO + ); + }; + } + + public Context getContext() { + return context; + } + + public BtLEQueue getQueue() { + return queue; + } + + public void setQueue(BtLEQueue queue) { + this.queue = queue; + } + + private void registerBleApiCharacteristicReceivers(boolean enable){ + if(enable == intentApiCharacteristicReceiverRegistered) { + return; + } + + if(enable){ + IntentFilter filter = new IntentFilter(); + filter.addAction(BLE_API_COMMAND_READ); + filter.addAction(BLE_API_COMMAND_WRITE); + + ContextCompat.registerReceiver( + getContext(), + intentApiReceiver, + filter, + ContextCompat.RECEIVER_EXPORTED + ); + }else{ + getContext().unregisterReceiver(intentApiReceiver); + } + intentApiCharacteristicReceiverRegistered = intentApiEnabledReadWrite; + } + + public void addService(BluetoothGattService service) { + for (BluetoothGattCharacteristic characteristic : service.getCharacteristics()) { + this.characteristics.put(characteristic.getUuid().toString(), characteristic); + } + } + + public void handleBLEApiPrefs(){ + Prefs devicePrefs = GBApplication.getDevicePrefs(getDevice().getAddress()); + this.intentApiEnabledReadWrite = devicePrefs.getBoolean(PREFS_KEY_DEVICE_BLE_API_DEVICE_READ_WRITE, false); + this.intentApiEnabledNotifications = devicePrefs.getBoolean(PREFS_KEY_DEVICE_BLE_API_DEVICE_NOTIFY, false); + this.intentApiEnabledDeviceState = devicePrefs.getBoolean(PREFS_KEY_DEVICE_BLE_API_DEVICE_STATE, false); + this.intentApiPackage = devicePrefs.getString(PREFS_KEY_DEVICE_BLE_API_PACKAGE, ""); + + registerBleApiCharacteristicReceivers(this.intentApiEnabledReadWrite); + } + + public static Intent getBleApiIntent(String deviceAddress, String action) { + Intent updateIntent = new Intent(action); + updateIntent.putExtra("EXTRA_DEVICE_ADDRESS", deviceAddress); + return updateIntent; + } + + private Intent getBleApiIntent(String action) { + return getBleApiIntent(getDevice().getAddress(), action); + } + + public BleIntentApi(Context context, GBDevice device) { + this.context = context; + this.device = device; + + this.logger = LoggerFactory.getLogger(BleIntentApi.class); + } + + public GBDevice getDevice() { + return device; + } + + private boolean concernsThisDevice(Intent intent) { + String deviceAddress = intent.getStringExtra("EXTRA_DEVICE_ADDRESS"); + if (StringUtils.isNullOrEmpty(deviceAddress)) { + logger.error("BLE API: missing EXTRA_DEVICE_ADDRESS"); + return false; + } + return deviceAddress.equalsIgnoreCase(getDevice().getAddress()); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gatt_client/BleGattClientCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gatt_client/BleGattClientCoordinator.java new file mode 100644 index 000000000..66fe6af90 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gatt_client/BleGattClientCoordinator.java @@ -0,0 +1,51 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.gatt_client; + +import androidx.annotation.NonNull; + +import nodomain.freeyourgadget.gadgetbridge.GBException; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; +import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; + +public class BleGattClientCoordinator extends AbstractBLEDeviceCoordinator { + @Override + public String getManufacturer() { + return "Generic"; + } + + @NonNull + @Override + public Class getDeviceSupportClass() { + return BleGattClientSupport.class; + } + + @Override + public boolean supports(GBDeviceCandidate candidate) { + // can only add through debug settings + return false; + } + + @Override + public int getDeviceNameResource() { + return R.string.devicetype_ble_gatt_client; + } + + @Override + public int getDefaultIconResource() { + return R.drawable.ic_device_scannable; + } + + @Override + public int getDisabledIconResource() { + return R.drawable.ic_device_scannable_disabled; + } + + @Override + protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException { + + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gatt_client/BleGattClientSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gatt_client/BleGattClientSupport.java new file mode 100644 index 000000000..141ad4e4b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gatt_client/BleGattClientSupport.java @@ -0,0 +1,71 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.gatt_client; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattService; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.UUID; + +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic; +import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction; + +public class BleGattClientSupport extends AbstractBTLEDeviceSupport { + public static final Logger logger = LoggerFactory.getLogger(BleGattClientSupport.class); + + public BleGattClientSupport() { + super(logger); + + addSupportedService(GattService.UUID_SERVICE_BATTERY_SERVICE); + addSupportedService(GattService.UUID_SERVICE_DEVICE_INFORMATION); + } + + @Override + public boolean useAutoConnect() { + return false; + } + + @Override + public boolean onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + if(characteristic.getUuid().equals(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL)) { + GBDeviceEventBatteryInfo batteryInfo = new GBDeviceEventBatteryInfo(); + batteryInfo.level = characteristic.getValue()[0]; + handleGBDeviceEvent(batteryInfo); + }else if(characteristic.getUuid().equals(GattCharacteristic.UUID_CHARACTERISTIC_FIRMWARE_REVISION_STRING)) { + String firmwareVersion = characteristic.getStringValue(0); + getDevice().setFirmwareVersion(firmwareVersion); + getDevice().sendDeviceUpdateIntent(getContext()); + } + return super.onCharacteristicRead(gatt, characteristic, status); + } + + void readCharacteristicIfAvailable(UUID characteristicUUID, TransactionBuilder builder) { + BluetoothGattCharacteristic characteristic = getCharacteristic(characteristicUUID); + if(characteristic == null) { + return; + } + + logger.debug("found characteristic {}", characteristicUUID); + builder.read(characteristic); + } + + @Override + protected TransactionBuilder initializeDevice(TransactionBuilder builder) { + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); + + readCharacteristicIfAvailable(GattCharacteristic.UUID_CHARACTERISTIC_BATTERY_LEVEL, builder); + readCharacteristicIfAvailable(GattCharacteristic.UUID_CHARACTERISTIC_FIRMWARE_REVISION_STRING, builder); + + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext())); + + return builder; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java index 994f0e951..f25b9ec9c 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java @@ -190,6 +190,20 @@ public class StringUtils { return GB.hexdump(array, 0, -1); } + public static byte[] hexToBytes(String hexString) { + if((hexString.length() % 2) == 1) { + // pad with zero + hexString = "0" + hexString; + } + byte[] bytes = new byte[hexString.length() / 2]; + for(int i = 0; i < bytes.length; i++) { + String slice = hexString.substring(i * 2, i * 2 + 2); + bytes[i] = (byte) Integer.parseInt(slice, 16); + } + + return bytes; + } + /** * Creates a shortened version of an Android package name by using only the first * character of every non-last part of the package name. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4a1009579..5bfbb0c4a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3255,4 +3255,13 @@ The following functionalities have been deprecated and will be removed soon from the software.\nIf you need to enable one of the following settings be sure to get in touch with the project team. Deprecated media control Send media control commands as key events instead of the media controller. + Generic BLE GATT Client + Broadcast GATT notification Intents through BLE Intent API + Receive BLE characteristic changes through Intents + Allow GATT interaction through BLE Intent API + Allow to send BLE characteristic read/write and connect commands + Receive BLE connection state changes via Intents + BLE API package + Restrict BLE Intent API communication to this package + BLE Intent API diff --git a/app/src/main/res/xml/devicesettings_ble_api.xml b/app/src/main/res/xml/devicesettings_ble_api.xml new file mode 100644 index 000000000..e9ab0cea6 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_ble_api.xml @@ -0,0 +1,27 @@ + + + + + + + + + + +