I'm building a BLE adapter for an medical device. It scans, connects, sets up notifications, and requests data. Everything works when the adapter first finds and connects to the device, but if the device disconnects (intentional or accidental), subsequent scans often do not find the device. After a few app reopens or good amount of time and rescan attempts, it eventually shows up and reconnects.
Full class (sensitive values redacted):
public class ThermometerAdapter implements BleDeviceManager.DeviceAdapter {
private static final String TAG = "ThermoMeterAdapter";
private static final String DEVICE_NAME = "My Thermometer";
private static final int DEFAULT_TIMEOUT_SEC = 20;
private static final int SCAN_RETRY_MAX = 5;
// GATT UUIDs
private static final UUID SERVICE_UUID = UUID.fromString("<UUID>");
private static final UUID CHAR_UUID = UUID.fromString("<UUID>");
// private static final UUID CCCD_UUID = UUID.fromString("<UUID>");
// Data frame constants
private static final byte START_CODE = (byte) 0xaa;
private static final byte TYPE_EAR = 0x22;
private static final byte TYPE_FOREHEAD = 0x33;
private static final byte TYPE_OBJECT = 0x55;
// Retry policy
private static final long RESCAN_DELAY_MS = 2500;
private static final long KEEPALIVE_INTERVAL_MS = 30000; // 30 seconds
private final Handler main = new Handler(Looper.getMainLooper());
private Context appCtx;
private InternalCallback cb;
private BleDeviceManager.VitalType requestedType;
private volatile boolean shouldRun;
private volatile boolean isConnected;
private long attempts;
private BluetoothAdapter btAdapter;
private BluetoothGatt gatt;
private BluetoothDevice targetDevice;
private ScanCallback scanCallback;
@Override
public void start(Context ctx, BleDeviceManager.VitalType type, InternalCallback callback) {
stop();
this.appCtx = ctx.getApplicationContext();
this.cb = callback;
this.requestedType = type;
this.shouldRun = true;
this.isConnected = false;
this.attempts = 0;
postStatus("Initializing thermometer…");
main.postDelayed(this::startScan, 300);
}
@RequiresPermission(allOf = {Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN})
@Override
public void stop() {
shouldRun = false;
isConnected = false;
main.removeCallbacksAndMessages(null);
// Stop scanning first
try {
if (btAdapter != null && scanCallback != null) {
BluetoothLeScanner scanner = btAdapter.getBluetoothLeScanner();
if (scanner != null) {
scanner.stopScan(scanCallback);
Log.d(TAG, "Scanner stopped");
}
}
} catch (Throwable t) {
Log.w(TAG, "stop scanner", t);
}
// Then disconnect GATT
try {
if (gatt != null) {
gatt.disconnect();
// Small delay before close to ensure disconnect completes
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
gatt.close();
gatt = null;
Log.d(TAG, "GATT closed");
}
} catch (Throwable t) {
Log.w(TAG, "stop gatt", t);
}
targetDevice = null;
scanCallback = null;
Log.d(TAG, "Adapter stopped completely");
}
// ===== Scanning =====
private void startScan() {
if (!shouldRun) return;
if (attempts >= SCAN_RETRY_MAX) {
postError("Failed to find thermometer after " + SCAN_RETRY_MAX + " attempts.");
Log.d(TAG,"Failed to find thermometer after " + SCAN_RETRY_MAX + " attempts.");
return;
}
attempts++;
postStatus("Scanning for thermometer (" + attempts + ")…");
Log.d(TAG,"Scanning for thermometer (" + attempts + ")…");
try {
btAdapter = BluetoothAdapter.getDefaultAdapter();
if (btAdapter == null) {
postError("Bluetooth adapter not available");
Log.d(TAG,"Bluetooth adapter not available");
return;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (!hasPermission(Manifest.permission.BLUETOOTH_SCAN)) {
postError("BLUETOOTH_SCAN permission denied");
Log.d(TAG,"BLUETOOTH_SCAN permission denied");
return;
}
}
BluetoothLeScanner scanner = btAdapter.getBluetoothLeScanner();
if (scanner == null) {
postError("BLE scanner not available");
scheduleRetry();
return;
}
scanCallback = new ScanCallback() {
@Override
public void onScanResult(int callbackType, ScanResult result) {
if (!shouldRun) return;
String name = result.getDevice().getName();
if (name != null && name.trim().equalsIgnoreCase(DEVICE_NAME.trim())) {
Log.d(TAG, "Found " + DEVICE_NAME);
targetDevice = result.getDevice();
postStatus("Found " + DEVICE_NAME + " — connecting…");
// Stop scanning before connecting
try {
scanner.stopScan(this);
} catch (SecurityException e) {
Log.w(TAG, "Failed to stop scan", e);
}
connectToDevice();
}
}
@Override
public void onScanFailed(int errorCode) {
if (!shouldRun) return;
postStatus("Scan failed (code: " + errorCode + "). Retrying…");
scheduleRetry();
}
};
scanner.startScan(scanCallback);
// Timeout for scan
main.postDelayed(() -> {
if (shouldRun && !isConnected && targetDevice == null && scanCallback != null) {
try {
scanner.stopScan(scanCallback);
} catch (Exception ignored) {}
postStatus("No devices found. Retrying…");
scheduleRetry();
}
}, DEFAULT_TIMEOUT_SEC * 1000L);
} catch (SecurityException e) {
Log.e(TAG, "startScan: permission issue", e);
postError("Bluetooth permission denied: " + e.getMessage());
} catch (Exception e) {
Log.e(TAG, "startScan: error", e);
postError("Scan error: " + e.getMessage());
scheduleRetry();
}
}
// ===== Connection & GATT =====
private void connectToDevice() {
if (!shouldRun || targetDevice == null) return;
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (!hasPermission(Manifest.permission.BLUETOOTH_CONNECT)) {
postError("BLUETOOTH_CONNECT permission denied");
return;
}
}
// Ensure any previous GATT is closed
if (gatt != null) {
Log.d(TAG, "Closing previous GATT before new connection");
try {
gatt.disconnect();
gatt.close();
} catch (Exception e) {
Log.w(TAG, "Error closing previous GATT", e);
}
gatt = null;
}
postStatus("Connecting…");
gatt = targetDevice.connectGatt(appCtx, false, gattCallback);
} catch (SecurityException e) {
Log.e(TAG, "connectToDevice: permission issue", e);
postError("Connection permission denied");
} catch (Exception e) {
Log.e(TAG, "connectToDevice: error", e);
postError("Connection failed: " + e.getMessage());
scheduleRetry();
}
}
private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
@Override
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
if (!shouldRun) return;
if (newState == BluetoothProfile.STATE_CONNECTED) {
isConnected = true;
postStatus("Connected. Discovering services…");
// Request high priority connection for better stability
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
try {
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH);
} catch (Exception e) {
Log.w(TAG, "Failed to request connection priority", e);
}
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (!hasPermission(Manifest.permission.BLUETOOTH_CONNECT)) return;
}
// Small delay before service discovery for stability
main.postDelayed(() -> {
if (gatt != null && shouldRun) {
gatt.discoverServices();
}
}, 300);
} catch (SecurityException e) {
Log.e(TAG, "onConnectionStateChange: permission", e);
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
postStatus("Disconnected.");
closeGatt();
if (shouldRun) scheduleRetry();
}
}
@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
if (!shouldRun) return;
if (status != BluetoothGatt.GATT_SUCCESS) {
postError("Service discovery failed (status: " + status + ")");
closeGatt();
scheduleRetry();
return;
}
postStatus("Services discovered. Enabling notifications…");
enableNotifications(gatt);
}
@Override
public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic,
byte[] value) {
if (!shouldRun) return;
Log.d(TAG, "onCharacteristicChanged: " + bytesToHex(value));
handleTempData(value);
}
@Override
public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic,
int status) {
if (!shouldRun) return;
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "onCharacteristicWrite failed: " + status);
}
}
@Override
public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
if (!shouldRun) return;
if (status == BluetoothGatt.GATT_SUCCESS) {
postStatus("Waiting for temperature reading…");
// startKeepalive();
} else {
postError("Failed to enable notifications (status: " + status + ")");
closeGatt();
scheduleRetry();
}
}
};
private void enableNotifications(BluetoothGatt gatt) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (!hasPermission(Manifest.permission.BLUETOOTH_CONNECT)) return;
}
BluetoothGattService service = gatt.getService(SERVICE_UUID);
if (service == null) {
postError("Service 0xFFF0 not found");
closeGatt();
scheduleRetry();
return;
}
BluetoothGattCharacteristic characteristic = service.getCharacteristic(CHAR_UUID);
if (characteristic == null) {
postError("Characteristic 0xFFF3 not found");
closeGatt();
scheduleRetry();
return;
}
// Enable notifications
gatt.setCharacteristicNotification(characteristic, true);
BluetoothGattDescriptor descriptor = characteristic.getDescriptor(
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") // CCCD
);
if (descriptor != null) {
descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.writeDescriptor(descriptor);
}
} catch (SecurityException e) {
Log.e(TAG, "enableNotifications: permission", e);
} catch (Exception e) {
Log.e(TAG, "enableNotifications: error", e);
postError("Notification setup failed: " + e.getMessage());
closeGatt();
scheduleRetry();
}
}
// private void startKeepalive() {
// // Cancel any existing keepalive
// if (keepaliveRunnable != null) {
// main.removeCallbacks(keepaliveRunnable);
// }
//
// keepaliveRunnable = new Runnable() {
// @Override
// public void run() {
// if (shouldRun && isConnected && gatt != null) {
// try {
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// if (!hasPermission(Manifest.permission.BLUETOOTH_CONNECT)) {
// return;
// }
// }
//
// // Read RSSI as a lightweight keepalive operation
// boolean readStarted = gatt.readRemoteRssi();
// if (!readStarted) {
// Log.w(TAG, "Keepalive: readRemoteRssi returned false");
// }
// } catch (SecurityException e) {
// Log.w(TAG, "Keepalive: permission denied", e);
// } catch (Exception e) {
// Log.w(TAG, "Keepalive: error", e);
// }
//
// // Schedule next keepalive
// main.postDelayed(this, KEEPALIVE_INTERVAL_MS);
// }
// }
// };
//
// // Start first keepalive
// main.postDelayed(keepaliveRunnable, KEEPALIVE_INTERVAL_MS);
// Log.d(TAG, "Keepalive started (interval: " + (KEEPALIVE_INTERVAL_MS / 1000) + "s)");
// }
// ===== Temperature Data Parsing =====
private void handleTempData(byte[] data) {
if (data == null || data.length < 5) {
Log.w(TAG, "Invalid data length: " + (data != null ? data.length : 0));
return;
}
try {
// Parse frame
byte startCode = data[0];
byte typeCode = data[1];
int tempRaw = ((data[2] & 0xFF) << 8) | (data[3] & 0xFF);
byte checkCode = data[4];
// Validate start code
if (startCode != START_CODE) {
Log.w(TAG, "Invalid start code: " + String.format("0x%02x", startCode));
return;
}
// Validate checksum
if (!validateChecksum(data, checkCode)) {
Log.w(TAG, "Checksum validation failed");
return;
}
// Convert temperature: divide by 10
float temperature = tempRaw / 100.0f;
temperature = celsiusToFahrenheit(temperature);
// Get temperature type name for logging
String tempType = getTemperatureTypeName(typeCode);
Log.d(TAG, "Temperature (" + tempType + "): " + temperature + "°C");
// Post the value
postValue(BleDeviceManager.VitalType.BODY_TEMPERATURE, temperature);
postStatus("Got " + tempType + ": " + temperature + "°C");
// Keep listening for more readings
// (don't close on success - device may send multiple readings)
} catch (Exception e) {
Log.e(TAG, "handleTempData: error", e);
postError("Data parse error: " + e.getMessage());
}
}
private float celsiusToFahrenheit(float celsius) {
return (celsius * 9.0f / 5.0f) + 32.0f;
}
private boolean validateChecksum(byte[] data, byte receivedCheck) {
int sum = 0;
// Sum first 4 bytes
for (int i = 0; i < 4; i++) {
sum += (data[i] & 0xFF);
}
byte calculatedCheck = (byte) (sum & 0xFF);
return calculatedCheck == receivedCheck;
}
private String getTemperatureTypeName(byte typeCode) {
switch (typeCode) {
case TYPE_EAR:
return "Ear";
case TYPE_FOREHEAD:
return "Forehead";
case TYPE_OBJECT:
return "Object";
default:
return "Unknown (0x" + String.format("%02x", typeCode) + ")";
}
}
// ===== Helpers =====
private void scheduleRetry() {
if (!shouldRun) return;
if (attempts >= SCAN_RETRY_MAX) {
postError("Max retry attempts reached.");
return;
}
// Clean up before retry
targetDevice = null;
// Ensure GATT is closed before rescanning
if (gatt != null) {
try {
gatt.close();
} catch (Exception e) {
Log.w(TAG, "Error closing GATT in scheduleRetry", e);
}
gatt = null;
}
postStatus("Retrying in " + (RESCAN_DELAY_MS / 1000f) + "s…");
main.postDelayed(this::startScan, RESCAN_DELAY_MS);
}
private void closeGatt() {
try {
if (gatt != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (hasPermission(Manifest.permission.BLUETOOTH_CONNECT)) {
gatt.disconnect();
// Give disconnect time to complete
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
gatt.close();
}
} else {
gatt.disconnect();
// Give disconnect time to complete
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
gatt.close();
}
}
} catch (SecurityException e) {
Log.e(TAG, "closeGatt: permission", e);
} finally {
gatt = null;
isConnected = false;
}
}
private boolean hasPermission(String permission) {
return ContextCompat.checkSelfPermission(appCtx, permission) == PackageManager.PERMISSION_GRANTED;
}
private String bytesToHex(byte[] bytes) {
if (bytes == null) return "";
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x ", b));
}
return sb.toString().trim();
}
private void postStatus(String s) {
if (cb != null) cb.postStatus(s);
}
private void postError(String e) {
if (cb != null) cb.postError(e);
}
private void postValue(BleDeviceManager.VitalType t, float v) {
if (cb != null) cb.postValue(t, v);
}
}