addServiceQueue;
+ private BluetoothGattCharacteristic sakeCharacteristic;
+ private AdvertiseCallback advertiseCallback;
+ private final SakeHandler sakeHandler = new SakeHandler();
// Permission check method
public boolean hasBluetoothPermissions() {
// For API 31+ (Android 12+), we need BLUETOOTH_CONNECT and BLUETOOTH_ADVERTISE
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
- return context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED &&
- context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED;
+ return context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT)
+ == PackageManager.PERMISSION_GRANTED
+ && context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_ADVERTISE)
+ == PackageManager.PERMISSION_GRANTED;
}
// For API 23-30 (Android 6.0 - 11), we need ACCESS_FINE_LOCATION for Bluetooth operations
else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
- return context.checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED;
+ return context.checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION)
+ == PackageManager.PERMISSION_GRANTED;
}
// For API < 23, no runtime permissions needed
else {
@@ -93,15 +107,16 @@ public void requestBluetoothPermissions() {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
// For Android 12+
- activity.requestPermissions(new String[]{
- android.Manifest.permission.BLUETOOTH_CONNECT,
- android.Manifest.permission.BLUETOOTH_ADVERTISE
- }, 101);
+ activity.requestPermissions(
+ new String[] {
+ android.Manifest.permission.BLUETOOTH_CONNECT,
+ android.Manifest.permission.BLUETOOTH_ADVERTISE
+ },
+ 101);
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
// For Android 6.0 - 11
- activity.requestPermissions(new String[]{
- android.Manifest.permission.ACCESS_FINE_LOCATION
- }, 101);
+ activity.requestPermissions(
+ new String[] {android.Manifest.permission.ACCESS_FINE_LOCATION}, 101);
}
// For Android < 6.0, no runtime permissions needed
}
@@ -154,29 +169,31 @@ private void startAdvertising() {
}
// Create advertisement data
- AdvertiseSettings settings = new AdvertiseSettings.Builder()
- .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED)
- .setConnectable(true)
- .setTimeout(0) // Advertise until explicitly stopped
- .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
- .build();
+ AdvertiseSettings settings =
+ new AdvertiseSettings.Builder()
+ .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED)
+ .setConnectable(true)
+ .setTimeout(0) // Advertise until explicitly stopped
+ .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
+ .build();
AdvertiseData data = createAdvertisementData();
- AdvertiseCallback callback = new AdvertiseCallback() {
- @Override
- public void onStartSuccess(AdvertiseSettings settingsInEffect) {
- Log.i(TAG, "Advertising started successfully");
- Log.d(TAG, "Advertise settings: " + settingsInEffect);
- }
+ advertiseCallback =
+ new AdvertiseCallback() {
+ @Override
+ public void onStartSuccess(AdvertiseSettings settingsInEffect) {
+ Log.i(TAG, "Advertising started successfully");
+ Log.d(TAG, "Advertise settings: " + settingsInEffect);
+ }
- @Override
- public void onStartFailure(int errorCode) {
- Log.e(TAG, "Advertising failed with error: " + errorCode);
- }
- };
+ @Override
+ public void onStartFailure(int errorCode) {
+ Log.e(TAG, "Advertising failed with error: " + errorCode);
+ }
+ };
- advertiser.startAdvertising(settings, data, callback);
+ advertiser.startAdvertising(settings, data, advertiseCallback);
Log.d(TAG, "Advertising started");
}
@@ -194,7 +211,7 @@ private AdvertiseData createAdvertisementData() {
// Add service data for the custom service
byte[] serviceData = new byte[] {0x01}; // Simple service data
- // builder.addServiceData(serviceUuid, serviceData);
+ // builder.addServiceData(serviceUuid, serviceData);
return builder.build();
}
@@ -238,119 +255,131 @@ private void startGattServer() {
}
private BluetoothGattService createDeviceInfoService() {
- BluetoothGattService service = new BluetoothGattService(
- DEVICE_INFO_SERVICE_UUID,
- BluetoothGattService.SERVICE_TYPE_PRIMARY
- );
+ BluetoothGattService service =
+ new BluetoothGattService(
+ DEVICE_INFO_SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY);
// Manufacturer Name String [R]
- BluetoothGattCharacteristic manufacturerName = new BluetoothGattCharacteristic(
- MANUFACTURER_NAME_UUID,
- BluetoothGattCharacteristic.PROPERTY_READ,
- BluetoothGattCharacteristic.PERMISSION_READ
- );
+ BluetoothGattCharacteristic manufacturerName =
+ new BluetoothGattCharacteristic(
+ MANUFACTURER_NAME_UUID,
+ BluetoothGattCharacteristic.PROPERTY_READ,
+ BluetoothGattCharacteristic.PERMISSION_READ);
manufacturerName.setValue("Test Manufacturer");
service.addCharacteristic(manufacturerName);
// Model Number String [R]
- BluetoothGattCharacteristic modelNumber = new BluetoothGattCharacteristic(
- MODEL_NUMBER_UUID,
- BluetoothGattCharacteristic.PROPERTY_READ,
- BluetoothGattCharacteristic.PERMISSION_READ
- );
+ BluetoothGattCharacteristic modelNumber =
+ new BluetoothGattCharacteristic(
+ MODEL_NUMBER_UUID,
+ BluetoothGattCharacteristic.PROPERTY_READ,
+ BluetoothGattCharacteristic.PERMISSION_READ);
modelNumber.setValue("Test Model Number");
service.addCharacteristic(modelNumber);
// Serial Number String [R]
- BluetoothGattCharacteristic serialNumber = new BluetoothGattCharacteristic(
- SERIAL_NUMBER_UUID,
- BluetoothGattCharacteristic.PROPERTY_READ,
- BluetoothGattCharacteristic.PERMISSION_READ
- );
+ BluetoothGattCharacteristic serialNumber =
+ new BluetoothGattCharacteristic(
+ SERIAL_NUMBER_UUID,
+ BluetoothGattCharacteristic.PROPERTY_READ,
+ BluetoothGattCharacteristic.PERMISSION_READ);
serialNumber.setValue(MOBILE_NAME);
service.addCharacteristic(serialNumber);
// Hardware Revision String [R]
- BluetoothGattCharacteristic hardwareRevision = new BluetoothGattCharacteristic(
- HARDWARE_REVISION_UUID,
- BluetoothGattCharacteristic.PROPERTY_READ,
- BluetoothGattCharacteristic.PERMISSION_READ
- );
+ BluetoothGattCharacteristic hardwareRevision =
+ new BluetoothGattCharacteristic(
+ HARDWARE_REVISION_UUID,
+ BluetoothGattCharacteristic.PROPERTY_READ,
+ BluetoothGattCharacteristic.PERMISSION_READ);
hardwareRevision.setValue("HW-1.0");
service.addCharacteristic(hardwareRevision);
// Firmware Revision String [R]
- BluetoothGattCharacteristic firmwareRevision = new BluetoothGattCharacteristic(
- FIRMWARE_REVISION_UUID,
- BluetoothGattCharacteristic.PROPERTY_READ,
- BluetoothGattCharacteristic.PERMISSION_READ
- );
+ BluetoothGattCharacteristic firmwareRevision =
+ new BluetoothGattCharacteristic(
+ FIRMWARE_REVISION_UUID,
+ BluetoothGattCharacteristic.PROPERTY_READ,
+ BluetoothGattCharacteristic.PERMISSION_READ);
firmwareRevision.setValue("14587043");
service.addCharacteristic(firmwareRevision);
// Software Revision String [R]
- BluetoothGattCharacteristic softwareRevision = new BluetoothGattCharacteristic(
- SOFTWARE_REVISION_UUID,
- BluetoothGattCharacteristic.PROPERTY_READ,
- BluetoothGattCharacteristic.PERMISSION_READ
- );
- // softwareRevision.setValue("SW-3.2.1");
+ BluetoothGattCharacteristic softwareRevision =
+ new BluetoothGattCharacteristic(
+ SOFTWARE_REVISION_UUID,
+ BluetoothGattCharacteristic.PROPERTY_READ,
+ BluetoothGattCharacteristic.PERMISSION_READ);
+ // softwareRevision.setValue("SW-3.2.1");
softwareRevision.setValue(FAKE_APP_VER); // APK version with git commit?
service.addCharacteristic(softwareRevision);
// System ID [R]
- BluetoothGattCharacteristic systemId = new BluetoothGattCharacteristic(
- SYSTEM_ID_UUID,
- BluetoothGattCharacteristic.PROPERTY_READ,
- BluetoothGattCharacteristic.PERMISSION_READ
- );
- systemId.setValue(new byte[]{(byte) 0x0, (byte) 0x0, (byte) 0x0, (byte) 0x0, (byte) 0x0, (byte)0x0, (byte) 0x0, (byte) 0x0});
+ BluetoothGattCharacteristic systemId =
+ new BluetoothGattCharacteristic(
+ SYSTEM_ID_UUID,
+ BluetoothGattCharacteristic.PROPERTY_READ,
+ BluetoothGattCharacteristic.PERMISSION_READ);
+ systemId.setValue(
+ new byte[] {
+ (byte) 0x0,
+ (byte) 0x0,
+ (byte) 0x0,
+ (byte) 0x0,
+ (byte) 0x0,
+ (byte) 0x0,
+ (byte) 0x0,
+ (byte) 0x0
+ });
service.addCharacteristic(systemId);
// PnP ID [R]
- BluetoothGattCharacteristic pnpId = new BluetoothGattCharacteristic(
- PNP_ID_UUID,
- BluetoothGattCharacteristic.PROPERTY_READ,
- BluetoothGattCharacteristic.PERMISSION_READ
- );
- pnpId.setValue(new byte[]{0x0});
+ BluetoothGattCharacteristic pnpId =
+ new BluetoothGattCharacteristic(
+ PNP_ID_UUID,
+ BluetoothGattCharacteristic.PROPERTY_READ,
+ BluetoothGattCharacteristic.PERMISSION_READ);
+ pnpId.setValue(new byte[] {0x0});
service.addCharacteristic(pnpId);
// IEEE 11073-20601 Regulatory Certification Data List [R]
- BluetoothGattCharacteristic regulatoryCert = new BluetoothGattCharacteristic(
- REGULATORY_CERT_UUID,
- BluetoothGattCharacteristic.PROPERTY_READ,
- BluetoothGattCharacteristic.PERMISSION_READ
- );
- regulatoryCert.setValue(new byte[]{});
+ BluetoothGattCharacteristic regulatoryCert =
+ new BluetoothGattCharacteristic(
+ REGULATORY_CERT_UUID,
+ BluetoothGattCharacteristic.PROPERTY_READ,
+ BluetoothGattCharacteristic.PERMISSION_READ);
+ regulatoryCert.setValue(new byte[] {});
service.addCharacteristic(regulatoryCert);
return service;
}
private BluetoothGattService createSakeService() {
- BluetoothGattService service = new BluetoothGattService(
- SAKE_SERVICE_UUID,
- BluetoothGattService.SERVICE_TYPE_PRIMARY
- );
+ BluetoothGattService service =
+ new BluetoothGattService(
+ SAKE_SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY);
// Unknown Characteristic [N W]
- BluetoothGattCharacteristic sakeChar = new BluetoothGattCharacteristic(
- SAKE_CHARACTERISTIC_UUID,
- BluetoothGattCharacteristic.PROPERTY_NOTIFY | BluetoothGattCharacteristic.PROPERTY_WRITE,
- BluetoothGattCharacteristic.PERMISSION_WRITE
- );
+ BluetoothGattCharacteristic sakeChar =
+ new BluetoothGattCharacteristic(
+ SAKE_CHARACTERISTIC_UUID,
+ BluetoothGattCharacteristic.PROPERTY_NOTIFY
+ | BluetoothGattCharacteristic.PROPERTY_WRITE,
+ BluetoothGattCharacteristic.PERMISSION_WRITE);
// Add Client Characteristic Configuration descriptor
- BluetoothGattDescriptor cccDescriptor = new BluetoothGattDescriptor(
- CCC_DESCRIPTOR_UUID,
- BluetoothGattDescriptor.PERMISSION_READ | BluetoothGattDescriptor.PERMISSION_WRITE
- );
+ BluetoothGattDescriptor cccDescriptor =
+ new BluetoothGattDescriptor(
+ CCC_DESCRIPTOR_UUID,
+ BluetoothGattDescriptor.PERMISSION_READ
+ | BluetoothGattDescriptor.PERMISSION_WRITE);
sakeChar.addDescriptor(cccDescriptor);
service.addCharacteristic(sakeChar);
+ this.sakeCharacteristic = sakeChar;
+
return service;
}
@@ -366,6 +395,7 @@ private void addNextService() {
Log.d(TAG, "All services added successfully");
Log.d(TAG, "Device Info Service UUID: " + DEVICE_INFO_SERVICE_UUID);
Log.d(TAG, "SAKE Service UUID: " + SAKE_SERVICE_UUID);
+ sakeHandler.attach(gattServer, sakeCharacteristic);
} else {
BluetoothGattService firstItem = addServiceQueue.poll();
if (firstItem != null) {
@@ -380,18 +410,17 @@ private void addNextService() {
}
private void stopAdvertising() {
- if (advertiser != null) {
- try {
- advertiser.stopAdvertising(new AdvertiseCallback() {
- @Override
- public void onStartSuccess(AdvertiseSettings settingsInEffect) {
- // Not expected for stop
- }
- });
- Log.d(TAG, "Advertising stopped");
- } catch (SecurityException e) {
- Log.e(TAG, "Security exception when stopping advertising: " + e.getMessage());
- }
+ if (advertiser == null || advertiseCallback == null) {
+ return;
+ }
+ try {
+ // Must pass the same callback instance startAdvertising() used.
+ advertiser.stopAdvertising(advertiseCallback);
+ Log.d(TAG, "Advertising stopped");
+ } catch (SecurityException e) {
+ Log.e(TAG, "Security exception when stopping advertising: " + e.getMessage());
+ } finally {
+ advertiseCallback = null;
}
}
@@ -408,189 +437,311 @@ private void stopGattServer() {
}
// GATT Server Callback
- private final BluetoothGattServerCallback gattServerCallback = new BluetoothGattServerCallback() {
- @Override
- public void onConnectionStateChange(BluetoothDevice device, int status, int newState) {
- super.onConnectionStateChange(device, status, newState);
-
- String deviceAddress = device != null ? device.getAddress() : "unknown";
-
- if (newState == BluetoothProfile.STATE_CONNECTED) {
- Log.i(TAG, "Device connected: " + deviceAddress);
- Log.d(TAG, "Connection status: " + status);
-
- // Stop advertising when connected
- stopAdvertising();
-
- // Set security requirements - No Input No Output (Just Works)
- /*
- if (device != null) {
- device.setPairingConfirmation(true);
- Log.d(TAG, "Pairing confirmation set to true for: " + deviceAddress);
+ private final BluetoothGattServerCallback gattServerCallback =
+ new BluetoothGattServerCallback() {
+ @Override
+ public void onConnectionStateChange(
+ BluetoothDevice device, int status, int newState) {
+ super.onConnectionStateChange(device, status, newState);
+
+ String deviceAddress = device != null ? device.getAddress() : "unknown";
+
+ if (newState == BluetoothProfile.STATE_CONNECTED) {
+ Log.i(TAG, "Device connected: " + deviceAddress);
+ Log.d(TAG, "Connection status: " + status);
+
+ // Stop advertising when connected
+ stopAdvertising();
+
+ // Set security requirements - No Input No Output (Just Works)
+ /*
+ if (device != null) {
+ device.setPairingConfirmation(true);
+ Log.d(TAG, "Pairing confirmation set to true for: " + deviceAddress);
+ }
+ */
+
+ } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
+ Log.i(TAG, "Device disconnected: " + deviceAddress);
+ Log.d(TAG, "Disconnection status: " + status);
+
+ // Restart advertising when disconnected
+ startAdvertising();
+ }
}
- */
-
- } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
- Log.i(TAG, "Device disconnected: " + deviceAddress);
- Log.d(TAG, "Disconnection status: " + status);
-
- // Restart advertising when disconnected
- startAdvertising();
- }
- }
-
- @Override
- public void onServiceAdded(int status, BluetoothGattService service) {
- super.onServiceAdded(status, service);
- Log.d(TAG, "Service added: " + service.getUuid() + " status: " + status);
- if (status == BluetoothGatt.GATT_SUCCESS) {
- Log.d(TAG, "Service added successfully: " + service.getUuid());
- addNextService();
- } else {
- Log.e(TAG, "Failed to add service: " + service.getUuid() + " with status: " + status);
- }
- }
-
- @Override
- public void onCharacteristicReadRequest(BluetoothDevice device, int requestId, int offset, BluetoothGattCharacteristic characteristic) {
- super.onCharacteristicReadRequest(device, requestId, offset, characteristic);
-
- var uuid = characteristic.getUuid();
- Log.i(TAG, "Read request from: " + device.getAddress());
- Log.d(TAG, "Characteristic UUID: " + uuid);
- Log.d(TAG, "Request ID: " + requestId + ", Offset: " + offset);
-
- try {
- byte[] toSend = "DummyData".getBytes(StandardCharsets.UTF_8);
-
- if (uuid.equals(SOFTWARE_REVISION_UUID)) {
- toSend = FAKE_APP_VER.getBytes(StandardCharsets.UTF_8); // might not be necessary?
+ @Override
+ public void onServiceAdded(int status, BluetoothGattService service) {
+ super.onServiceAdded(status, service);
+ Log.d(TAG, "Service added: " + service.getUuid() + " status: " + status);
+
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ Log.d(TAG, "Service added successfully: " + service.getUuid());
+ addNextService();
+ } else {
+ Log.e(
+ TAG,
+ "Failed to add service: "
+ + service.getUuid()
+ + " with status: "
+ + status);
+ }
}
- if (uuid.equals(SYSTEM_ID_UUID)) {
- toSend = new byte[]{0, 0, 0, 0,0, 0, 0, 0};
+ @Override
+ public void onCharacteristicReadRequest(
+ BluetoothDevice device,
+ int requestId,
+ int offset,
+ BluetoothGattCharacteristic characteristic) {
+ super.onCharacteristicReadRequest(device, requestId, offset, characteristic);
+
+ var uuid = characteristic.getUuid();
+ Log.i(TAG, "Read request from: " + device.getAddress());
+ Log.d(TAG, "Characteristic UUID: " + uuid);
+ Log.d(TAG, "Request ID: " + requestId + ", Offset: " + offset);
+
+ try {
+ byte[] stored = characteristic.getValue();
+ byte[] toSend =
+ stored != null
+ ? stored
+ : "DummyData".getBytes(StandardCharsets.UTF_8);
+
+ if (uuid.equals(SOFTWARE_REVISION_UUID)) {
+ toSend =
+ FAKE_APP_VER.getBytes(
+ StandardCharsets.UTF_8); // might not be necessary?
+ }
+
+ if (uuid.equals(SYSTEM_ID_UUID)) {
+ toSend = new byte[] {0, 0, 0, 0, 0, 0, 0, 0};
+ }
+
+ if (uuid.equals(PNP_ID_UUID)) {
+ // https://btprodspecificationrefs.blob.core.windows.net/gatt-specification-supplement/GATT_Specification_Supplement.pdf
+ // vid source, vendor id, product id, product version,
+ toSend =
+ new byte[] {
+ 0, 0, 0, 0, 0, 0, 0,
+ };
+ }
+ gattServer.sendResponse(
+ device, requestId, BluetoothGatt.GATT_SUCCESS, offset, toSend);
+
+ Log.d(TAG, "Sent response: 0x " + bytesToHex(toSend));
+ } catch (SecurityException e) {
+ Log.e(TAG, "Security exception in read request: " + e.getMessage());
+ }
}
- if (uuid.equals(PNP_ID_UUID)) {
- // https://btprodspecificationrefs.blob.core.windows.net/gatt-specification-supplement/GATT_Specification_Supplement.pdf
- // vid source, vendor id, product id, product version,
- toSend = new byte[]{0, 0, 0, 0,0, 0, 0, };
+ @Override
+ public void onCharacteristicWriteRequest(
+ BluetoothDevice device,
+ int requestId,
+ BluetoothGattCharacteristic characteristic,
+ boolean preparedWrite,
+ boolean responseNeeded,
+ int offset,
+ byte[] value) {
+ super.onCharacteristicWriteRequest(
+ device,
+ requestId,
+ characteristic,
+ preparedWrite,
+ responseNeeded,
+ offset,
+ value);
+
+ Log.i(TAG, "Write request from: " + device.getAddress());
+ Log.d(TAG, "Characteristic UUID: " + characteristic.getUuid());
+ Log.d(TAG, "Value: " + bytesToHex(value));
+ Log.d(
+ TAG,
+ "Prepared write: "
+ + preparedWrite
+ + ", Response needed: "
+ + responseNeeded);
+
+ if (characteristic.getUuid().equals(SAKE_CHARACTERISTIC_UUID)) {
+ sakeHandler.onWrite(value);
+ }
+ if (responseNeeded) {
+ try {
+ gattServer.sendResponse(
+ device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value);
+ } catch (SecurityException e) {
+ Log.e(TAG, "Security exception in write request: " + e.getMessage());
+ }
+ }
}
- gattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, toSend);
-
- Log.d(TAG, "Sent response: 0x " + bytesToHex(toSend));
- } catch (SecurityException e) {
- Log.e(TAG, "Security exception in read request: " + e.getMessage());
- }
- }
-
- @Override
- public void onCharacteristicWriteRequest(BluetoothDevice device, int requestId, BluetoothGattCharacteristic characteristic, boolean preparedWrite, boolean responseNeeded, int offset, byte[] value) {
- super.onCharacteristicWriteRequest(device, requestId, characteristic, preparedWrite, responseNeeded, offset, value);
- Log.i(TAG, "Write request from: " + device.getAddress());
- Log.d(TAG, "Characteristic UUID: " + characteristic.getUuid());
- Log.d(TAG, "Value: " + bytesToHex(value));
- Log.d(TAG, "Prepared write: " + preparedWrite + ", Response needed: " + responseNeeded);
-
- if (responseNeeded) {
- try {
- gattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value);
- } catch (SecurityException e) {
- Log.e(TAG, "Security exception in write request: " + e.getMessage());
+ @Override
+ public void onDescriptorReadRequest(
+ BluetoothDevice device,
+ int requestId,
+ int offset,
+ BluetoothGattDescriptor descriptor) {
+ super.onDescriptorReadRequest(device, requestId, offset, descriptor);
+
+ Log.i(TAG, "Descriptor read request from: " + device.getAddress());
+ Log.d(TAG, "Descriptor UUID: " + descriptor.getUuid());
+
+ try {
+ // For CCC descriptor, return current value (0 by default)
+ byte[] value = descriptor.getValue();
+ if (value == null) {
+ value = new byte[] {0x00, 0x00}; // Default CCC value
+ }
+ gattServer.sendResponse(
+ device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value);
+
+ Log.d(TAG, "Sent descriptor value: " + bytesToHex(value));
+ } catch (SecurityException e) {
+ Log.e(
+ TAG,
+ "Security exception in descriptor read request: " + e.getMessage());
+ }
}
- }
- }
-
- @Override
- public void onDescriptorReadRequest(BluetoothDevice device, int requestId, int offset, BluetoothGattDescriptor descriptor) {
- super.onDescriptorReadRequest(device, requestId, offset, descriptor);
- Log.i(TAG, "Descriptor read request from: " + device.getAddress());
- Log.d(TAG, "Descriptor UUID: " + descriptor.getUuid());
+ @Override
+ public void onDescriptorWriteRequest(
+ BluetoothDevice device,
+ int requestId,
+ BluetoothGattDescriptor descriptor,
+ boolean preparedWrite,
+ boolean responseNeeded,
+ int offset,
+ byte[] value) {
+ super.onDescriptorWriteRequest(
+ device,
+ requestId,
+ descriptor,
+ preparedWrite,
+ responseNeeded,
+ offset,
+ value);
+
+ Log.i(TAG, "Descriptor write request from: " + device.getAddress());
+ Log.d(TAG, "Descriptor UUID: " + descriptor.getUuid());
+ Log.d(TAG, "Value: " + bytesToHex(value));
+
+ if (descriptor.getUuid().equals(CCC_DESCRIPTOR_UUID)) {
+ if (value == null || value.length < 2) {
+ Log.w(TAG, "CCC write with malformed value, ignoring");
+ if (responseNeeded) {
+ try {
+ gattServer.sendResponse(
+ device,
+ requestId,
+ BluetoothGatt.GATT_INVALID_ATTRIBUTE_LENGTH,
+ offset,
+ null);
+ } catch (SecurityException e) {
+ Log.e(
+ TAG,
+ "Security exception in CCC malformed-value response: "
+ + e.getMessage());
+ }
+ }
+ return;
+ }
+ int cccValue = ((value[1] & 0xFF) << 8) | (value[0] & 0xFF);
+
+ if ((cccValue & 0x0001) != 0) {
+ Log.i(TAG, "Client subscribed to NOTIFICATIONS");
+ }
+ if ((cccValue & 0x0002) != 0) {
+ Log.i(TAG, "Client subscribed to INDICATIONS");
+ }
+ if (cccValue == 0x0000) {
+ Log.i(TAG, "Client unsubscribed from notifications/indications");
+ }
+
+ boolean isSakeChar =
+ descriptor.getCharacteristic() != null
+ && SAKE_CHARACTERISTIC_UUID.equals(
+ descriptor.getCharacteristic().getUuid());
+ if (isSakeChar) {
+ if ((cccValue & 0x0001) != 0) {
+ sakeHandler.onNotificationsEnabled(device);
+ } else if (cccValue == 0x0000) {
+ sakeHandler.onNotificationsDisabled();
+ }
+ }
+ }
- try {
- // For CCC descriptor, return current value (0 by default)
- byte[] value = descriptor.getValue();
- if (value == null) {
- value = new byte[]{0x00, 0x00}; // Default CCC value
+ // Update descriptor value
+ descriptor.setValue(value);
+
+ if (responseNeeded) {
+ try {
+ gattServer.sendResponse(
+ device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value);
+ } catch (SecurityException e) {
+ Log.e(
+ TAG,
+ "Security exception in descriptor write request: "
+ + e.getMessage());
+ }
+ }
}
- gattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value);
-
- Log.d(TAG, "Sent descriptor value: " + bytesToHex(value));
- } catch (SecurityException e) {
- Log.e(TAG, "Security exception in descriptor read request: " + e.getMessage());
- }
- }
-
- @Override
- public void onDescriptorWriteRequest(BluetoothDevice device, int requestId, BluetoothGattDescriptor descriptor, boolean preparedWrite, boolean responseNeeded, int offset, byte[] value) {
- super.onDescriptorWriteRequest(device, requestId, descriptor, preparedWrite, responseNeeded, offset, value);
-
- Log.i(TAG, "Descriptor write request from: " + device.getAddress());
- Log.d(TAG, "Descriptor UUID: " + descriptor.getUuid());
- Log.d(TAG, "Value: " + bytesToHex(value));
- if (descriptor.getUuid().equals(CCC_DESCRIPTOR_UUID)) {
- int cccValue = (value[1] << 8) | (value[0] & 0xFF);
-
- if ((cccValue & 0x0001) != 0) {
- Log.i(TAG, "Client subscribed to NOTIFICATIONS");
- }
- if ((cccValue & 0x0002) != 0) {
- Log.i(TAG, "Client subscribed to INDICATIONS");
- }
- if (cccValue == 0x0000) {
- Log.i(TAG, "Client unsubscribed from notifications/indications");
+ @Override
+ public void onExecuteWrite(BluetoothDevice device, int requestId, boolean execute) {
+ super.onExecuteWrite(device, requestId, execute);
+ Log.d(
+ TAG,
+ "Execute write from: " + device.getAddress() + ", Execute: " + execute);
}
- }
-
- // Update descriptor value
- descriptor.setValue(value);
- if (responseNeeded) {
- try {
- gattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value);
- } catch (SecurityException e) {
- Log.e(TAG, "Security exception in descriptor write request: " + e.getMessage());
+ @Override
+ public void onNotificationSent(BluetoothDevice device, int status) {
+ super.onNotificationSent(device, status);
+ Log.d(
+ TAG,
+ "Notification sent to: " + device.getAddress() + ", Status: " + status);
}
- }
- }
-
- @Override
- public void onExecuteWrite(BluetoothDevice device, int requestId, boolean execute) {
- super.onExecuteWrite(device, requestId, execute);
- Log.d(TAG, "Execute write from: " + device.getAddress() + ", Execute: " + execute);
- }
- @Override
- public void onNotificationSent(BluetoothDevice device, int status) {
- super.onNotificationSent(device, status);
- Log.d(TAG, "Notification sent to: " + device.getAddress() + ", Status: " + status);
- }
-
- @Override
- public void onMtuChanged(BluetoothDevice device, int mtu) {
- super.onMtuChanged(device, mtu);
- Log.i(TAG, "MTU changed for device: " + device.getAddress() + ", New MTU: " + mtu);
- }
+ @Override
+ public void onMtuChanged(BluetoothDevice device, int mtu) {
+ super.onMtuChanged(device, mtu);
+ Log.i(
+ TAG,
+ "MTU changed for device: " + device.getAddress() + ", New MTU: " + mtu);
+ }
- @Override
- public void onPhyUpdate(BluetoothDevice device, int txPhy, int rxPhy, int status) {
- super.onPhyUpdate(device, txPhy, rxPhy, status);
- Log.d(TAG, "PHY update for device: " + device.getAddress() +
- ", TX PHY: " + txPhy + ", RX PHY: " + rxPhy + ", Status: " + status);
- }
+ @Override
+ public void onPhyUpdate(BluetoothDevice device, int txPhy, int rxPhy, int status) {
+ super.onPhyUpdate(device, txPhy, rxPhy, status);
+ Log.d(
+ TAG,
+ "PHY update for device: "
+ + device.getAddress()
+ + ", TX PHY: "
+ + txPhy
+ + ", RX PHY: "
+ + rxPhy
+ + ", Status: "
+ + status);
+ }
- @Override
- public void onPhyRead(BluetoothDevice device, int txPhy, int rxPhy, int status) {
- super.onPhyRead(device, txPhy, rxPhy, status);
- Log.d(TAG, "PHY read for device: " + device.getAddress() +
- ", TX PHY: " + txPhy + ", RX PHY: " + rxPhy + ", Status: " + status);
- }
- };
+ @Override
+ public void onPhyRead(BluetoothDevice device, int txPhy, int rxPhy, int status) {
+ super.onPhyRead(device, txPhy, rxPhy, status);
+ Log.d(
+ TAG,
+ "PHY read for device: "
+ + device.getAddress()
+ + ", TX PHY: "
+ + txPhy
+ + ", RX PHY: "
+ + rxPhy
+ + ", Status: "
+ + status);
+ }
+ };
// Utility method to convert bytes to hex string
private static String bytesToHex(byte[] bytes) {
@@ -606,10 +757,11 @@ private static String bytesToHex(byte[] bytes) {
}
// Security callback for pairing/bonding
- private final BluetoothAdapter.LeScanCallback leScanCallback = new BluetoothAdapter.LeScanCallback() {
- @Override
- public void onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord) {
- // Not used in peripheral mode, but kept for reference
- }
- };
+ private final BluetoothAdapter.LeScanCallback leScanCallback =
+ new BluetoothAdapter.LeScanCallback() {
+ @Override
+ public void onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord) {
+ // Not used in peripheral mode, but kept for reference
+ }
+ };
}
diff --git a/app/src/main/java/org/openminimed/pumpconnector/MainActivity.java b/app/src/main/java/org/openminimed/pumpconnector/MainActivity.java
index 4f00a2f..5fa4553 100644
--- a/app/src/main/java/org/openminimed/pumpconnector/MainActivity.java
+++ b/app/src/main/java/org/openminimed/pumpconnector/MainActivity.java
@@ -1,17 +1,14 @@
package org.openminimed.pumpconnector;
import android.os.Bundle;
+import android.view.View;
+import android.widget.Button;
+import android.widget.Toast;
import androidx.activity.EdgeToEdge;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
-import android.view.View;
-import android.widget.Button;
-import android.widget.Toast;
-
-import org.openminimed.pumpconnector.BlePeripheralDevice;
-
public class MainActivity extends AppCompatActivity implements View.OnClickListener {
@@ -22,17 +19,19 @@ protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
setContentView(R.layout.activity_main);
- ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
- Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
- v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
- return insets;
- });
+ ViewCompat.setOnApplyWindowInsetsListener(
+ findViewById(R.id.main),
+ (v, insets) -> {
+ Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
+ v.setPadding(
+ systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
+ return insets;
+ });
this.ble = new BlePeripheralDevice(MainActivity.this);
Button btn = (Button) findViewById(R.id.start_gatt);
btn.setOnClickListener(MainActivity.this);
-
}
@Override
@@ -45,7 +44,5 @@ public void onClick(View v) {
this.ble.start();
Toast.makeText(this, "Peripheral started!", Toast.LENGTH_SHORT).show();
}
-
}
-
}
diff --git a/app/src/main/java/org/openminimed/pumpconnector/SakeHandler.java b/app/src/main/java/org/openminimed/pumpconnector/SakeHandler.java
new file mode 100644
index 0000000..6476c5b
--- /dev/null
+++ b/app/src/main/java/org/openminimed/pumpconnector/SakeHandler.java
@@ -0,0 +1,179 @@
+package org.openminimed.pumpconnector;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattServer;
+import android.os.Build;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.util.Log;
+import java.util.Objects;
+import org.openminimed.sake.Constants;
+import org.openminimed.sake.SakeServer;
+
+/**
+ * BLE glue layer for the SAKE handshake.
+ *
+ * Owns a {@link SakeServer} pinned to the extracted-from-pump key database and translates
+ * between BLE GATT server events and handshake step calls. All SAKE work is serialized onto a
+ * dedicated {@link HandlerThread} so the binder thread that fires BLE callbacks is never blocked by
+ * crypto work.
+ *
+ *
Wire protocol the pump expects, in order:
+ *
+ *
+ * - Pump subscribes to notifications on the SAKE characteristic. Handler emits twenty zero
+ * bytes as a wake-up notification.
+ *
- Pump writes its own twenty zero bytes as the matching wake-up. Handler feeds that to {@link
+ * SakeServer#handshake(byte[])}, gets msg0 back, notifies it to the pump.
+ *
- Pump writes msg1, msg3, msg5 in turn. Handler responds with msg2, msg4, and finally
+ * completes at stage 6.
+ *
+ *
+ * The handler is reusable across pump disconnect/reconnect cycles. A re-subscribe before
+ * completion is treated as an abort: the {@link SakeServer} is rebuilt from scratch and the
+ * handshake starts fresh with a new wake-up frame.
+ */
+public final class SakeHandler {
+
+ private static final String TAG = "SakeHandler";
+ private static final int HANDSHAKE_COMPLETE_STAGE = 6;
+ private static final byte[] WAKE_UP = new byte[20];
+
+ private final HandlerThread thread;
+ private final Handler handler;
+
+ private SakeServer server;
+ private BluetoothGattServer gattServer;
+ private BluetoothGattCharacteristic characteristic;
+ private BluetoothDevice peer;
+ private boolean pumpSubscribed;
+
+ private volatile boolean handshakeComplete;
+ private volatile boolean closed;
+
+ public SakeHandler() {
+ this.server = new SakeServer(Constants.KEYDB_PUMP_EXTRACTED);
+ this.thread = new HandlerThread("sake-handler");
+ this.thread.start();
+ this.handler = new Handler(this.thread.getLooper());
+ }
+
+ /**
+ * Bind the GATT server and characteristic the handler will use to send SAKE notifications back
+ * to the pump. Must be called before any of the {@code on*} hooks.
+ */
+ public void attach(BluetoothGattServer gattServer, BluetoothGattCharacteristic characteristic) {
+ Objects.requireNonNull(gattServer, "gattServer");
+ Objects.requireNonNull(characteristic, "characteristic");
+ this.gattServer = gattServer;
+ this.characteristic = characteristic;
+ }
+
+ /**
+ * Called from the GATT server callback when the pump subscribes to notifications on the SAKE
+ * characteristic. Emits a 20-byte wake-up frame.
+ *
+ *
If the handshake has not completed and the pump resubscribes (e.g. after a transient
+ * disconnect), the {@link SakeServer} is reset so the new wake-up starts a fresh handshake.
+ */
+ public void onNotificationsEnabled(BluetoothDevice device) {
+ if (closed) {
+ return;
+ }
+ handler.post(
+ () -> {
+ peer = device;
+ if (pumpSubscribed) {
+ return;
+ }
+ if (!handshakeComplete && server.getStage() != 0) {
+ Log.w(TAG, "Pump resubscribed mid-handshake; restarting SAKE state");
+ server = new SakeServer(Constants.KEYDB_PUMP_EXTRACTED);
+ }
+ pumpSubscribed = true;
+ Log.i(TAG, "Pump subscribed to SAKE notifications; sending wake-up");
+ sendNotification(WAKE_UP.clone());
+ });
+ }
+
+ /** Called from the GATT server callback when the pump unsubscribes. */
+ public void onNotificationsDisabled() {
+ if (closed) {
+ return;
+ }
+ handler.post(
+ () -> {
+ pumpSubscribed = false;
+ Log.w(TAG, "Pump unsubscribed from SAKE notifications");
+ });
+ }
+
+ /**
+ * Called from the GATT server callback for every write on the SAKE characteristic. Drives the
+ * next handshake step and emits the response.
+ */
+ public void onWrite(byte[] value) {
+ if (closed) {
+ return;
+ }
+ byte[] copy = value.clone();
+ handler.post(
+ () -> {
+ if (handshakeComplete) {
+ Log.w(TAG, "Ignoring write after handshake completion");
+ return;
+ }
+ try {
+ byte[] response = server.handshake(copy);
+ if (response != null) {
+ sendNotification(response);
+ } else {
+ handshakeComplete = true;
+ Log.i(TAG, "SAKE handshake complete");
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "SAKE handshake failed", e);
+ }
+ });
+ }
+
+ /**
+ * @return true once the handshake has reached stage 6.
+ */
+ public boolean isHandshakeComplete() {
+ return handshakeComplete;
+ }
+
+ /** Stop the worker thread and release resources. Subsequent {@code on*} calls become no-ops. */
+ public void close() {
+ closed = true;
+ handler.removeCallbacksAndMessages(null);
+ handler.post(
+ () -> {
+ gattServer = null;
+ characteristic = null;
+ peer = null;
+ });
+ thread.quitSafely();
+ }
+
+ @SuppressWarnings("deprecation")
+ private void sendNotification(byte[] data) {
+ if (gattServer == null || characteristic == null || peer == null) {
+ Log.e(TAG, "Cannot send: handler is not attached or has no peer");
+ return;
+ }
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ gattServer.notifyCharacteristicChanged(peer, characteristic, false, data);
+ } else {
+ characteristic.setValue(data);
+ gattServer.notifyCharacteristicChanged(peer, characteristic, false);
+ }
+ Log.d(TAG, "Notified SAKE frame (" + data.length + " bytes)");
+ } catch (SecurityException e) {
+ Log.e(TAG, "Security exception sending SAKE notification", e);
+ }
+ }
+}
diff --git a/app/src/test/java/org/openminimed/pumpconnector/ExampleUnitTest.java b/app/src/test/java/org/openminimed/pumpconnector/ExampleUnitTest.java
index e6142ac..145359e 100644
--- a/app/src/test/java/org/openminimed/pumpconnector/ExampleUnitTest.java
+++ b/app/src/test/java/org/openminimed/pumpconnector/ExampleUnitTest.java
@@ -1,9 +1,9 @@
package org.openminimed.pumpconnector;
-import org.junit.Test;
-
import static org.junit.Assert.*;
+import org.junit.Test;
+
/**
* Example local unit test, which will execute on the development machine (host).
*
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 273bbb9..757c4c3 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -7,6 +7,8 @@ appcompat = "1.7.1"
material = "1.13.0"
activity = "1.12.2"
constraintlayout = "2.2.1"
+spotless = "6.25.0"
+googleJavaFormat = "1.22.0"
[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
@@ -19,4 +21,5 @@ constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayo
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
+spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index c77005b..367e74f 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -21,3 +21,5 @@ dependencyResolutionManagement {
rootProject.name = "PumpConnector"
include(":app")
+
+includeBuild("JavaSake")