|
|
|
|
@@ -14,6 +14,7 @@ import android.os.Handler;
|
|
|
|
|
import android.os.Looper;
|
|
|
|
|
import android.util.Log;
|
|
|
|
|
|
|
|
|
|
import java.lang.reflect.Method;
|
|
|
|
|
import java.util.Arrays;
|
|
|
|
|
import java.util.List;
|
|
|
|
|
import java.util.UUID;
|
|
|
|
|
@@ -28,8 +29,13 @@ public final class BleCentralClient {
|
|
|
|
|
|
|
|
|
|
private static final UUID CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
|
|
|
|
|
|
|
|
|
|
private static final long SCAN_TIMEOUT_MS = 10_000L;
|
|
|
|
|
private static final long SCAN_TIMEOUT_MS = 30_000L;
|
|
|
|
|
private static final long CONNECT_TIMEOUT_MS = 12_000L;
|
|
|
|
|
private static final long RECONNECT_DELAY_MS = 1_000L;
|
|
|
|
|
private static final long PING_INTERVAL_MS = 15_000L;
|
|
|
|
|
private static final int MAX_MISSED_PINGS = 2;
|
|
|
|
|
private static final long STALE_TIMEOUT_MS = PING_INTERVAL_MS * MAX_MISSED_PINGS;
|
|
|
|
|
private static final long LIVENESS_CHECK_INTERVAL_MS = 5_000L;
|
|
|
|
|
|
|
|
|
|
private final Context appContext;
|
|
|
|
|
private final Callback callback;
|
|
|
|
|
@@ -41,10 +47,12 @@ public final class BleCentralClient {
|
|
|
|
|
private BluetoothDevice lastDevice = null;
|
|
|
|
|
private BluetoothGatt gatt = null;
|
|
|
|
|
private boolean subscribed = false;
|
|
|
|
|
private int backoffMs = 1000;
|
|
|
|
|
|
|
|
|
|
private long lastNotificationAtMs = 0L;
|
|
|
|
|
private long lastPingAtMs = 0L;
|
|
|
|
|
private long lastUnparsedLogAtMs = 0L;
|
|
|
|
|
private long lastScanLogAtMs = 0L;
|
|
|
|
|
private String lastScanLogAddr = null;
|
|
|
|
|
|
|
|
|
|
private final Runnable scanTimeoutRunnable = new Runnable() {
|
|
|
|
|
@Override
|
|
|
|
|
@@ -52,7 +60,7 @@ public final class BleCentralClient {
|
|
|
|
|
if (!scanning) return;
|
|
|
|
|
Log.i(Constants.TAG_BLE, "Scan timeout; restarting");
|
|
|
|
|
stopScanInternal();
|
|
|
|
|
scheduleReconnect("Scan timeout");
|
|
|
|
|
scheduleScanRestart("Scan timeout");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@@ -66,6 +74,24 @@ public final class BleCentralClient {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private final Runnable livenessRunnable = new Runnable() {
|
|
|
|
|
@Override
|
|
|
|
|
public void run() {
|
|
|
|
|
if (gatt == null || !subscribed) return;
|
|
|
|
|
long now = System.currentTimeMillis();
|
|
|
|
|
long last = lastNotificationAtMs;
|
|
|
|
|
long age = last > 0L ? (now - last) : 0L;
|
|
|
|
|
if (last > 0L && age > STALE_TIMEOUT_MS) {
|
|
|
|
|
long pingAge = lastPingAtMs > 0L ? (now - lastPingAtMs) : age;
|
|
|
|
|
Log.w(Constants.TAG_BLE, "Stale BLE (no ping/notify for " + age + "ms; last ping " + pingAge + "ms ago); reconnecting");
|
|
|
|
|
closeGattInternal();
|
|
|
|
|
scheduleReconnect("Stale (no ping)");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
handler.postDelayed(this, LIVENESS_CHECK_INTERVAL_MS);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private final BluetoothAdapter.LeScanCallback leScanCallback = new BluetoothAdapter.LeScanCallback() {
|
|
|
|
|
@Override
|
|
|
|
|
public void onLeScan(final BluetoothDevice device, final int rssi, final byte[] scanRecord) {
|
|
|
|
|
@@ -132,7 +158,6 @@ public final class BleCentralClient {
|
|
|
|
|
handler.post(new Runnable() {
|
|
|
|
|
@Override
|
|
|
|
|
public void run() {
|
|
|
|
|
backoffMs = 1000;
|
|
|
|
|
startScanInternal("Start");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
@@ -144,7 +169,6 @@ public final class BleCentralClient {
|
|
|
|
|
public void run() {
|
|
|
|
|
Log.i(Constants.TAG_BLE, "Rescan requested");
|
|
|
|
|
closeGattInternal();
|
|
|
|
|
backoffMs = 1000;
|
|
|
|
|
startScanInternal("Rescan");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
@@ -166,6 +190,7 @@ public final class BleCentralClient {
|
|
|
|
|
if (device == null) return;
|
|
|
|
|
|
|
|
|
|
Advert adv = Advert.parse(scanRecord);
|
|
|
|
|
maybeLogScan(device, rssi, scanRecord, adv);
|
|
|
|
|
boolean nameMatch = (adv != null && Constants.PERIPHERAL_NAME_HINT.equals(adv.localName))
|
|
|
|
|
|| Constants.PERIPHERAL_NAME_HINT.equals(device.getName());
|
|
|
|
|
boolean serviceMatch = adv != null && adv.hasService(Constants.SERVICE_UUID);
|
|
|
|
|
@@ -183,11 +208,30 @@ public final class BleCentralClient {
|
|
|
|
|
connectInternal(device);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void maybeLogScan(BluetoothDevice device, int rssi, byte[] scanRecord, Advert adv) {
|
|
|
|
|
long now = System.currentTimeMillis();
|
|
|
|
|
String addr = device.getAddress();
|
|
|
|
|
boolean addrChanged = addr != null && !addr.equals(lastScanLogAddr);
|
|
|
|
|
if (!addrChanged && (now - lastScanLogAtMs) < 1000L) return;
|
|
|
|
|
|
|
|
|
|
lastScanLogAtMs = now;
|
|
|
|
|
lastScanLogAddr = addr;
|
|
|
|
|
|
|
|
|
|
String advName = adv != null ? adv.localName : null;
|
|
|
|
|
String services = adv != null ? formatUuids(adv.services) : "[]";
|
|
|
|
|
Log.d(Constants.TAG_BLE,
|
|
|
|
|
"Scan result: addr=" + addr
|
|
|
|
|
+ " name=" + device.getName()
|
|
|
|
|
+ " advName=" + advName
|
|
|
|
|
+ " services=" + services
|
|
|
|
|
+ " rssi=" + rssi
|
|
|
|
|
+ " len=" + (scanRecord != null ? scanRecord.length : 0)
|
|
|
|
|
+ " hex=" + hexPrefix(scanRecord, 24));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void scheduleReconnect(String reason) {
|
|
|
|
|
if (callback != null) callback.onStatus("Disconnected", reason, true);
|
|
|
|
|
int delay = backoffMs;
|
|
|
|
|
backoffMs = Math.min(30_000, backoffMs * 2);
|
|
|
|
|
|
|
|
|
|
long delay = RECONNECT_DELAY_MS;
|
|
|
|
|
Log.i(Constants.TAG_BLE, "Reconnect in " + delay + "ms (" + reason + ")");
|
|
|
|
|
handler.postDelayed(new Runnable() {
|
|
|
|
|
@Override
|
|
|
|
|
@@ -197,6 +241,18 @@ public final class BleCentralClient {
|
|
|
|
|
}, delay);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void scheduleScanRestart(String reason) {
|
|
|
|
|
if (callback != null) callback.onStatus("Scanning…", reason, true);
|
|
|
|
|
long delay = RECONNECT_DELAY_MS;
|
|
|
|
|
Log.i(Constants.TAG_BLE, "Restart scan in " + delay + "ms (" + reason + ")");
|
|
|
|
|
handler.postDelayed(new Runnable() {
|
|
|
|
|
@Override
|
|
|
|
|
public void run() {
|
|
|
|
|
startScanInternal("Scan restart");
|
|
|
|
|
}
|
|
|
|
|
}, delay);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void startScanInternal(String why) {
|
|
|
|
|
if (adapter == null) {
|
|
|
|
|
if (callback != null) callback.onStatus("BLE unsupported", "No BluetoothAdapter", true);
|
|
|
|
|
@@ -209,6 +265,9 @@ public final class BleCentralClient {
|
|
|
|
|
if (scanning) return;
|
|
|
|
|
|
|
|
|
|
subscribed = false;
|
|
|
|
|
stopLivenessCheck();
|
|
|
|
|
lastNotificationAtMs = 0L;
|
|
|
|
|
lastPingAtMs = 0L;
|
|
|
|
|
if (callback != null) callback.onStatus("Scanning…", null, true);
|
|
|
|
|
Log.i(Constants.TAG_BLE, "startLeScan (" + why + ")");
|
|
|
|
|
// Scan broadly; filter by advertised service UUID and/or local name hint in the callback.
|
|
|
|
|
@@ -258,7 +317,6 @@ public final class BleCentralClient {
|
|
|
|
|
|
|
|
|
|
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
|
|
|
|
handler.removeCallbacks(connectTimeoutRunnable);
|
|
|
|
|
backoffMs = 1000;
|
|
|
|
|
if (callback != null) {
|
|
|
|
|
callback.onStatus("Discovering…", null, true);
|
|
|
|
|
callback.onConnected();
|
|
|
|
|
@@ -325,6 +383,9 @@ public final class BleCentralClient {
|
|
|
|
|
Log.i(Constants.TAG_BLE, "onDescriptorWrite CCCD status=" + status);
|
|
|
|
|
if (status == BluetoothGatt.GATT_SUCCESS) {
|
|
|
|
|
subscribed = true;
|
|
|
|
|
lastNotificationAtMs = System.currentTimeMillis();
|
|
|
|
|
lastPingAtMs = 0L;
|
|
|
|
|
startLivenessCheck();
|
|
|
|
|
if (callback != null) callback.onStatus("Connected", null, true);
|
|
|
|
|
} else {
|
|
|
|
|
closeGattInternal();
|
|
|
|
|
@@ -359,6 +420,7 @@ public final class BleCentralClient {
|
|
|
|
|
|
|
|
|
|
if (result.isPing) {
|
|
|
|
|
Log.d(Constants.TAG_FEED, "PING");
|
|
|
|
|
lastPingAtMs = lastNotificationAtMs;
|
|
|
|
|
if (callback != null) callback.onPing();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
@@ -373,12 +435,15 @@ public final class BleCentralClient {
|
|
|
|
|
|
|
|
|
|
private void closeGattInternal() {
|
|
|
|
|
subscribed = false;
|
|
|
|
|
stopLivenessCheck();
|
|
|
|
|
handler.removeCallbacks(connectTimeoutRunnable);
|
|
|
|
|
if (gatt != null) {
|
|
|
|
|
try {
|
|
|
|
|
gatt.disconnect();
|
|
|
|
|
} catch (Throwable ignored) {
|
|
|
|
|
}
|
|
|
|
|
// Hidden API: best-effort cache refresh to avoid stale services on reconnect.
|
|
|
|
|
refreshDeviceCache(gatt);
|
|
|
|
|
try {
|
|
|
|
|
gatt.close();
|
|
|
|
|
} catch (Throwable ignored) {
|
|
|
|
|
@@ -387,6 +452,15 @@ public final class BleCentralClient {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void startLivenessCheck() {
|
|
|
|
|
handler.removeCallbacks(livenessRunnable);
|
|
|
|
|
handler.postDelayed(livenessRunnable, LIVENESS_CHECK_INTERVAL_MS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void stopLivenessCheck() {
|
|
|
|
|
handler.removeCallbacks(livenessRunnable);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@SuppressLint("MissingPermission")
|
|
|
|
|
private static boolean startLeScanCompat(UUID[] uuids, BluetoothAdapter.LeScanCallback callback) {
|
|
|
|
|
BluetoothAdapter a = BluetoothAdapter.getDefaultAdapter();
|
|
|
|
|
@@ -423,6 +497,20 @@ public final class BleCentralClient {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hidden API: clears stale GATT services on some older stacks; best-effort only.
|
|
|
|
|
private static void refreshDeviceCache(BluetoothGatt gatt) {
|
|
|
|
|
if (gatt == null) return;
|
|
|
|
|
try {
|
|
|
|
|
Method refresh = gatt.getClass().getMethod("refresh");
|
|
|
|
|
if (refresh != null) {
|
|
|
|
|
boolean ok = (Boolean) refresh.invoke(gatt);
|
|
|
|
|
Log.i(Constants.TAG_BLE, "refreshDeviceCache=" + ok);
|
|
|
|
|
}
|
|
|
|
|
} catch (Throwable t) {
|
|
|
|
|
Log.w(Constants.TAG_BLE, "refreshDeviceCache failed: " + t);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void logLarge(String tag, String s) {
|
|
|
|
|
if (s == null) {
|
|
|
|
|
Log.d(tag, "null");
|
|
|
|
|
@@ -533,4 +621,16 @@ public final class BleCentralClient {
|
|
|
|
|
if (b.length > n) sb.append(" …");
|
|
|
|
|
return sb.toString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static String formatUuids(List<UUID> uuids) {
|
|
|
|
|
if (uuids == null || uuids.isEmpty()) return "[]";
|
|
|
|
|
StringBuilder sb = new StringBuilder();
|
|
|
|
|
sb.append("[");
|
|
|
|
|
for (int i = 0; i < uuids.size(); i++) {
|
|
|
|
|
if (i > 0) sb.append(", ");
|
|
|
|
|
sb.append(uuids.get(i));
|
|
|
|
|
}
|
|
|
|
|
sb.append("]");
|
|
|
|
|
return sb.toString();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|