fix(ble): 옛 연결의 콜백이 새 연결 상태를 덮어쓰고 있었다
연결 해제 후 **빠르게** [연결] 을 누르면 연결이 안 됐다. 병원 임상·정렬·설정탭의
[연결 해제] 는 전부 같은 `disconnect()` 이고, 문제는 그 뒤에 있었다.
## 원인 — gattCallback 이 주인을 확인하지 않았다
`gattCallback` 은 객체 하나를 모든 연결이 공유한다. `gatt.disconnect()` 는 비동기라 옛
연결의 콜백이 **새 연결이 들어선 뒤에** 도착한다:
① [연결 해제] gatt.disconnect() ← 콜백은 몇 초 뒤
② 바로 [연결] 옛 gatt close + bluetoothGatt = 새 GATT
③ 옛 GATT 의 STATE_DISCONNECTED 도착 →
bluetoothGatt = null ← 새 참조를 지운다
isConnected.value = false
③ 이후 새 GATT 가 STATE_CONNECTED 를 받아도 그 분기는 `bluetoothGatt` 를 대입하지 않아
**isConnected=true 인데 bluetoothGatt 가 null** 이 된다. sendRaw 가 그 필드를 쓰므로
명령이 하나도 안 나간다. 반대로 isConnected 가 false 로 덮이면 화면만 "연결 안 됨".
늦게 온 **알림(onCharacteristicChanged)** 이 섞이면 더 나쁘다 — 다른 기기의 파형이
화면에 뜬다.
## 고친 것 셋
**① 주인 확인** `isStaleGatt()` 를 만들어 콜백 일곱 개 전부에 걸었다
(onConnectionStateChange · onMtuChanged · onServicesDiscovered · onDescriptorWrite ·
onCharacteristicChanged ×2 · onReadRemoteRssi). 주인이 아니면 **닫고 버린다** — 안 닫으면
그 핸들이 누수다. onConnectionStateChange 는 `handler.post` 안에서 **한 번 더** 본다:
post 사이에 새 연결이 들어설 수 있다.
`bluetoothGatt == null` 이면 통과시킨다. 연결을 막 만들어 대입 전인 구간이 있고, 그때
막으면 STATE_CONNECTED 를 놓쳐 영원히 연결되지 않는다.
**② STATE_CONNECTED 에서 `bluetoothGatt = gatt`** 뒷북이 지워도 여기서 다시 잡힌다.
**③ 재연결 쿨다운 600ms** close() 는 핸들만 돌려주고 컨트롤러의 링크 정리는 조금 뒤에
끝난다. 그 틈에 다시 열면 0x3E(status 62)나 반쪽 연결이 된다. 마지막 disconnect 로부터
충분히 지났으면 기다리지 않는다 — 평소 연결이 느려지면 안 된다.
예약은 하나만 둔다(두 번 열리면 하나가 고아가 된다). [연결 해제] 는 예약을 **취소**한다 —
안 그러면 끊은 뒤 600ms 만에 스스로 다시 연결된다.
테스트 143개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -435,6 +435,51 @@ class BleManager private constructor(private val context: Context) {
|
||||
*/
|
||||
private val CONNECT_TIMEOUT_MS = 20_000L
|
||||
|
||||
/**
|
||||
* 이 콜백이 **지금 쓰는 연결의 것**인가.
|
||||
*
|
||||
* `gattCallback` 은 객체 하나를 모든 연결이 공유한다. 그래서 옛 연결의 콜백이 새 연결이
|
||||
* 들어선 **뒤에** 도착할 수 있다 — `gatt.disconnect()` 가 비동기라, 연결 해제 직후
|
||||
* 빠르게 [연결] 을 누르면 실제로 그렇게 된다(2026-09-10 현장).
|
||||
*
|
||||
* 그때 옛 콜백이 상태를 덮으면:
|
||||
* · `bluetoothGatt = null` → isConnected=true 인데 sendRaw 가 하나도 안 나간다
|
||||
* · `isConnected = false` → 화면은 "연결 안 됨", 실제로는 붙어 있다
|
||||
* · 늦게 온 알림이 섞여 **다른 기기의 파형**이 화면에 뜬다
|
||||
*
|
||||
* 그래서 주인이 아니면 **닫고 버린다.** 닫는 것까지 하는 이유는 그 핸들을 우리가 더
|
||||
* 쓰지 않으므로 안 닫으면 누수이기 때문이다.
|
||||
*
|
||||
* `bluetoothGatt` 가 null 이면 **통과시킨다** — 연결을 막 만들어 대입 전인 구간이 있고,
|
||||
* 그때 막으면 STATE_CONNECTED 를 놓쳐 영원히 연결되지 않는다.
|
||||
*/
|
||||
/**
|
||||
* 닫은 직후 같은 기기로 다시 열기 전 대기.
|
||||
*
|
||||
* close() 는 핸들만 돌려주고 컨트롤러의 링크 정리는 조금 뒤에 끝난다. 600ms 는 실측
|
||||
* 로그에서 "2초 뒤 재시도는 성공"이던 구간을 사용자가 못 느낄 만큼으로 줄인 값이다.
|
||||
*/
|
||||
private val RECONNECT_COOLDOWN_MS = 600L
|
||||
|
||||
/** 마지막으로 연결을 끊은 시각. 쿨다운 계산에만 쓴다. */
|
||||
@Volatile private var lastDisconnectAtMs = 0L
|
||||
|
||||
/**
|
||||
* 쿨다운이 끝나면 연결할 예약. 두 번 예약되면 GATT 가 두 개 열린다.
|
||||
*
|
||||
* 취소도 해야 한다 — 쿨다운 중에 [연결 해제] 를 누르면, 안 끊으면 600ms 뒤에 제멋대로
|
||||
* 연결된다.
|
||||
*/
|
||||
private var pendingConnect: Runnable? = null
|
||||
|
||||
private fun isStaleGatt(gatt: BluetoothGatt, where: String): Boolean {
|
||||
val current = bluetoothGatt ?: return false
|
||||
if (gatt === current) return false
|
||||
logw { "stale GATT callback ignored at $where (${gatt.device.address})" }
|
||||
try { gatt.close() } catch (_: Exception) {}
|
||||
return true
|
||||
}
|
||||
|
||||
// Connection
|
||||
fun connect(bleDevice: BleDevice) {
|
||||
stopScan()
|
||||
@@ -447,6 +492,7 @@ class BleManager private constructor(private val context: Context) {
|
||||
logd { "Connecting to ${bleDevice.name} (${bleDevice.address})" }
|
||||
|
||||
// Clean up any existing GATT connection
|
||||
var closedOld = false
|
||||
bluetoothGatt?.let { gatt ->
|
||||
logd { "Closing existing GATT connection" }
|
||||
try {
|
||||
@@ -458,8 +504,44 @@ class BleManager private constructor(private val context: Context) {
|
||||
bluetoothGatt = null
|
||||
txCharacteristic = null
|
||||
rxCharacteristic = null
|
||||
closedOld = true
|
||||
}
|
||||
|
||||
// **닫은 직후 같은 기기로 바로 connectGatt 하지 않는다.**
|
||||
//
|
||||
// close() 는 핸들만 돌려주고 컨트롤러의 링크 정리는 조금 뒤에 끝난다. 그 틈에
|
||||
// 다시 열면 0x3E(GATT_CONN_FAIL_ESTABLISH · status 62)로 떨어지거나 반쪽 연결이
|
||||
// 된다 — 연결 해제 후 빠르게 [연결] 을 누를 때 나던 증상이다(2026-09-10).
|
||||
//
|
||||
// 마지막 disconnect 로부터 충분히 지났으면 기다리지 않는다 — 평소 연결이 느려지면
|
||||
// 안 된다.
|
||||
val sinceDisconnect = System.currentTimeMillis() - lastDisconnectAtMs
|
||||
val cooldown = when {
|
||||
closedOld -> RECONNECT_COOLDOWN_MS
|
||||
sinceDisconnect < RECONNECT_COOLDOWN_MS -> RECONNECT_COOLDOWN_MS - sinceDisconnect
|
||||
else -> 0L
|
||||
}
|
||||
if (cooldown > 0L) {
|
||||
logd { "reconnect cooldown ${cooldown}ms" }
|
||||
debugLogger.info("CONNECT_COOLDOWN ${cooldown}ms (스택 정리 대기)")
|
||||
// 앞 예약이 남아 있으면 버린다 — 두 번 열리면 둘 중 하나가 고아가 된다.
|
||||
pendingConnect?.let { handler.removeCallbacks(it) }
|
||||
val r = Runnable { pendingConnect = null; connectNow(bleDevice) }
|
||||
pendingConnect = r
|
||||
handler.postDelayed(r, cooldown)
|
||||
isConnecting.value = true
|
||||
connectionError.value = null
|
||||
val i = discoveredDevices.indexOfFirst { it.address == bleDevice.address }
|
||||
if (i >= 0) discoveredDevices[i] = discoveredDevices[i].copy(isConnecting = true)
|
||||
return
|
||||
}
|
||||
pendingConnect?.let { handler.removeCallbacks(it); pendingConnect = null }
|
||||
connectNow(bleDevice)
|
||||
}
|
||||
|
||||
/** 쿨다운이 끝난 뒤의 실제 연결. [connect] 가 전처리를 끝내고 부른다. */
|
||||
private fun connectNow(bleDevice: BleDevice) {
|
||||
|
||||
// 이전 디바이스의 state 가 남아있는 걸 방지 — 매 connect 마다 fresh.
|
||||
// 새 mid?/msn? 응답 받기 전까진 빈 값으로 보임 → 정확.
|
||||
// (STATE_DISCONNECTED 콜백이 안 떴거나 다른 디바이스로 바로 스위치하는 경우 대응)
|
||||
@@ -650,6 +732,9 @@ class BleManager private constructor(private val context: Context) {
|
||||
|
||||
|
||||
fun disconnect() {
|
||||
lastDisconnectAtMs = System.currentTimeMillis()
|
||||
// 쿨다운 예약을 버린다. 안 버리면 끊은 뒤 600ms 만에 스스로 다시 연결된다.
|
||||
pendingConnect?.let { handler.removeCallbacks(it); pendingConnect = null }
|
||||
isUserDisconnect = true
|
||||
cancelAutoReconnect()
|
||||
stopWatchdog()
|
||||
@@ -666,6 +751,8 @@ class BleManager private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
fun disconnectAndUnbond() {
|
||||
lastDisconnectAtMs = System.currentTimeMillis()
|
||||
pendingConnect?.let { handler.removeCallbacks(it); pendingConnect = null }
|
||||
isUserDisconnect = true
|
||||
cancelAutoReconnect()
|
||||
stopWatchdog()
|
||||
@@ -1451,7 +1538,10 @@ class BleManager private constructor(private val context: Context) {
|
||||
private val gattCallback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
logd { "onConnectionStateChange: status=$status newState=$newState" }
|
||||
if (isStaleGatt(gatt, "onConnectionStateChange status=$status newState=$newState")) return
|
||||
handler.post {
|
||||
// post 사이에 새 연결이 들어설 수 있다 — 한 번 더 본다.
|
||||
if (isStaleGatt(gatt, "onConnectionStateChange(post)")) return@post
|
||||
// Cancel connection timeout
|
||||
connectionTimer?.let { handler.removeCallbacks(it) }
|
||||
connectionTimer = null
|
||||
@@ -1528,6 +1618,14 @@ class BleManager private constructor(private val context: Context) {
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
logd { "Connected to ${gatt.device.address}" }
|
||||
// **이 연결을 현재 GATT 로 못박는다.**
|
||||
//
|
||||
// 재연결 경로는 `bluetoothGatt = connectGatt(...)` 로 대입하지만,
|
||||
// 옛 연결의 뒷북이 그 사이에 `bluetoothGatt = null` 로 덮을 수 있었다
|
||||
// (연결 해제 직후 빠르게 [연결] 을 누르면 그랬다 — 2026-09-10).
|
||||
// 그러면 isConnected=true 인데 bluetoothGatt 가 null 이라 sendRaw 가
|
||||
// 하나도 안 나간다. 여기서 다시 잡아 두면 그 상태가 남지 않는다.
|
||||
bluetoothGatt = gatt
|
||||
isReconnecting.value = false
|
||||
isConnecting.value = false
|
||||
isUserDisconnect = false
|
||||
@@ -1620,12 +1718,14 @@ class BleManager private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
|
||||
if (isStaleGatt(gatt, "onMtuChanged")) return
|
||||
logd { "MTU changed: $mtu (status=$status)" }
|
||||
handler.post { debugLogger.info("MTU=$mtu") }
|
||||
gatt.discoverServices()
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
if (isStaleGatt(gatt, "onServicesDiscovered")) return
|
||||
logd { "onServicesDiscovered: status=$status" }
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
handler.post { connectionError.value = "Service discovery failed (status $status)" }
|
||||
@@ -1682,6 +1782,7 @@ class BleManager private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
if (isStaleGatt(gatt, "onDescriptorWrite")) return
|
||||
logd { "onDescriptorWrite: status=$status uuid=${descriptor.uuid}" }
|
||||
if (descriptor.uuid == CCCD_UUID) {
|
||||
handler.post {
|
||||
@@ -1761,6 +1862,8 @@ class BleManager private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
|
||||
// 옛 연결에서 늦게 온 알림이 섞이면 다른 기기의 파형이 화면에 뜬다.
|
||||
if (isStaleGatt(gatt, "onCharacteristicChanged")) return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (characteristic.uuid == RX_CHAR_UUID) {
|
||||
if (value.size < 4) return
|
||||
@@ -1771,6 +1874,7 @@ class BleManager private constructor(private val context: Context) {
|
||||
|
||||
@Deprecated("Deprecated in API 33", ReplaceWith("onCharacteristicChanged(gatt, characteristic, value)"))
|
||||
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||
if (isStaleGatt(gatt, "onCharacteristicChanged(legacy)")) return
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
if (characteristic.uuid == RX_CHAR_UUID) {
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -1782,6 +1886,7 @@ class BleManager private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
|
||||
if (isStaleGatt(gatt, "onReadRemoteRssi")) return
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
handler.post {
|
||||
debugLogger.rssi(rssi)
|
||||
|
||||
Reference in New Issue
Block a user