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:
2026-09-10 14:29:42 +09:00
parent 338ae7a19d
commit b0351342de
@@ -435,6 +435,51 @@ class BleManager private constructor(private val context: Context) {
*/ */
private val CONNECT_TIMEOUT_MS = 20_000L 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 // Connection
fun connect(bleDevice: BleDevice) { fun connect(bleDevice: BleDevice) {
stopScan() stopScan()
@@ -447,6 +492,7 @@ class BleManager private constructor(private val context: Context) {
logd { "Connecting to ${bleDevice.name} (${bleDevice.address})" } logd { "Connecting to ${bleDevice.name} (${bleDevice.address})" }
// Clean up any existing GATT connection // Clean up any existing GATT connection
var closedOld = false
bluetoothGatt?.let { gatt -> bluetoothGatt?.let { gatt ->
logd { "Closing existing GATT connection" } logd { "Closing existing GATT connection" }
try { try {
@@ -458,8 +504,44 @@ class BleManager private constructor(private val context: Context) {
bluetoothGatt = null bluetoothGatt = null
txCharacteristic = null txCharacteristic = null
rxCharacteristic = 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. // 이전 디바이스의 state 가 남아있는 걸 방지 — 매 connect 마다 fresh.
// 새 mid?/msn? 응답 받기 전까진 빈 값으로 보임 → 정확. // 새 mid?/msn? 응답 받기 전까진 빈 값으로 보임 → 정확.
// (STATE_DISCONNECTED 콜백이 안 떴거나 다른 디바이스로 바로 스위치하는 경우 대응) // (STATE_DISCONNECTED 콜백이 안 떴거나 다른 디바이스로 바로 스위치하는 경우 대응)
@@ -650,6 +732,9 @@ class BleManager private constructor(private val context: Context) {
fun disconnect() { fun disconnect() {
lastDisconnectAtMs = System.currentTimeMillis()
// 쿨다운 예약을 버린다. 안 버리면 끊은 뒤 600ms 만에 스스로 다시 연결된다.
pendingConnect?.let { handler.removeCallbacks(it); pendingConnect = null }
isUserDisconnect = true isUserDisconnect = true
cancelAutoReconnect() cancelAutoReconnect()
stopWatchdog() stopWatchdog()
@@ -666,6 +751,8 @@ class BleManager private constructor(private val context: Context) {
} }
fun disconnectAndUnbond() { fun disconnectAndUnbond() {
lastDisconnectAtMs = System.currentTimeMillis()
pendingConnect?.let { handler.removeCallbacks(it); pendingConnect = null }
isUserDisconnect = true isUserDisconnect = true
cancelAutoReconnect() cancelAutoReconnect()
stopWatchdog() stopWatchdog()
@@ -1451,7 +1538,10 @@ class BleManager private constructor(private val context: Context) {
private val gattCallback = object : BluetoothGattCallback() { private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
logd { "onConnectionStateChange: status=$status newState=$newState" } logd { "onConnectionStateChange: status=$status newState=$newState" }
if (isStaleGatt(gatt, "onConnectionStateChange status=$status newState=$newState")) return
handler.post { handler.post {
// post 사이에 새 연결이 들어설 수 있다 — 한 번 더 본다.
if (isStaleGatt(gatt, "onConnectionStateChange(post)")) return@post
// Cancel connection timeout // Cancel connection timeout
connectionTimer?.let { handler.removeCallbacks(it) } connectionTimer?.let { handler.removeCallbacks(it) }
connectionTimer = null connectionTimer = null
@@ -1528,6 +1618,14 @@ class BleManager private constructor(private val context: Context) {
when (newState) { when (newState) {
BluetoothProfile.STATE_CONNECTED -> { BluetoothProfile.STATE_CONNECTED -> {
logd { "Connected to ${gatt.device.address}" } logd { "Connected to ${gatt.device.address}" }
// **이 연결을 현재 GATT 로 못박는다.**
//
// 재연결 경로는 `bluetoothGatt = connectGatt(...)` 로 대입하지만,
// 옛 연결의 뒷북이 그 사이에 `bluetoothGatt = null` 로 덮을 수 있었다
// (연결 해제 직후 빠르게 [연결] 을 누르면 그랬다 — 2026-09-10).
// 그러면 isConnected=true 인데 bluetoothGatt 가 null 이라 sendRaw 가
// 하나도 안 나간다. 여기서 다시 잡아 두면 그 상태가 남지 않는다.
bluetoothGatt = gatt
isReconnecting.value = false isReconnecting.value = false
isConnecting.value = false isConnecting.value = false
isUserDisconnect = false isUserDisconnect = false
@@ -1620,12 +1718,14 @@ class BleManager private constructor(private val context: Context) {
} }
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
if (isStaleGatt(gatt, "onMtuChanged")) return
logd { "MTU changed: $mtu (status=$status)" } logd { "MTU changed: $mtu (status=$status)" }
handler.post { debugLogger.info("MTU=$mtu") } handler.post { debugLogger.info("MTU=$mtu") }
gatt.discoverServices() gatt.discoverServices()
} }
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (isStaleGatt(gatt, "onServicesDiscovered")) return
logd { "onServicesDiscovered: status=$status" } logd { "onServicesDiscovered: status=$status" }
if (status != BluetoothGatt.GATT_SUCCESS) { if (status != BluetoothGatt.GATT_SUCCESS) {
handler.post { connectionError.value = "Service discovery failed (status $status)" } 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) { override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
if (isStaleGatt(gatt, "onDescriptorWrite")) return
logd { "onDescriptorWrite: status=$status uuid=${descriptor.uuid}" } logd { "onDescriptorWrite: status=$status uuid=${descriptor.uuid}" }
if (descriptor.uuid == CCCD_UUID) { if (descriptor.uuid == CCCD_UUID) {
handler.post { handler.post {
@@ -1761,6 +1862,8 @@ class BleManager private constructor(private val context: Context) {
} }
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) { override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
// 옛 연결에서 늦게 온 알림이 섞이면 다른 기기의 파형이 화면에 뜬다.
if (isStaleGatt(gatt, "onCharacteristicChanged")) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (characteristic.uuid == RX_CHAR_UUID) { if (characteristic.uuid == RX_CHAR_UUID) {
if (value.size < 4) return 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)")) @Deprecated("Deprecated in API 33", ReplaceWith("onCharacteristicChanged(gatt, characteristic, value)"))
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
if (isStaleGatt(gatt, "onCharacteristicChanged(legacy)")) return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
if (characteristic.uuid == RX_CHAR_UUID) { if (characteristic.uuid == RX_CHAR_UUID) {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@@ -1782,6 +1886,7 @@ class BleManager private constructor(private val context: Context) {
} }
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) { override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
if (isStaleGatt(gatt, "onReadRemoteRssi")) return
if (status == BluetoothGatt.GATT_SUCCESS) { if (status == BluetoothGatt.GATT_SUCCESS) {
handler.post { handler.post {
debugLogger.rssi(rssi) debugLogger.rssi(rssi)