From b0351342deb6f4515dcb179219c159a31c63d259 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Thu, 10 Sep 2026 14:29:42 +0900 Subject: [PATCH] =?UTF-8?q?fix(ble):=20=EC=98=9B=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=EC=9D=98=20=EC=BD=9C=EB=B0=B1=EC=9D=B4=20=EC=83=88=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EB=8D=AE=EC=96=B4?= =?UTF-8?q?=EC=93=B0=EA=B3=A0=20=EC=9E=88=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 연결 해제 후 **빠르게** [연결] 을 누르면 연결이 안 됐다. 병원 임상·정렬·설정탭의 [연결 해제] 는 전부 같은 `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 --- .../com/medithings/vesiscan/ble/BleManager.kt | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt index 8dd30df..0559c5d 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -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)