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)