fix(ble): 화면 전환 후 좀비 BLE 세션 방지 — 콜백 순서 + 정리

증상: 앱 실행 후 alignment → 도넛차트 → 개발자모드 → alignment 이동 시
BLE 가 끊긴 것처럼 응답 실종 → 15초 후 watchdog forced reconnect.
심하면 재연결도 좀비 상태로 나와 3번째 재연결에서야 정상 동작.

원인 1: BleManager.onConnectionStateChange 에서 STATE_CONNECTED 직후
onConnectionStateChanged?.invoke(true) 호출 → 이 시점에는 아직 MTU/
service discovery/CCCD write 이 완료되지 않아 txCharacteristic 은 null.
상위 (PiezoMonitoringView) 가 startBatteryPolling 등 TX 를 시도하면
sendRaw 내부 null-check 에 걸려 silent drop → peripheral 이 응답을
보내지 않음 → watchdog timeout.

원인 2: PiezoMonitoringView / PlacementGuideView 의 DisposableEffect
에서 BleManager 콜백 (onConnectionStateChanged, onUnexpectedDisconnect,
onReconnectionFailed, piezoCollector.onMultiChannelComplete 등) 을
정리하지 않음. 화면 전환 시 이전 화면의 stale 콜백이 남아 새 화면 진입
후에도 발동 → 상태 충돌.

수정:
- BleManager: onConnectionStateChanged(true) 호출을 CCCD write 완료
  (isServiceReady=true) 시점으로 이동. CCCD 없음 / RX 없음 edge case 도
  동일하게 처리.
- PiezoMonitoringView / PlacementGuideView: DisposableEffect 에서 등록한
  BleManager 콜백 전부 null 로 정리.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 10:10:57 +09:00
parent 159116dd94
commit f4f5407894
3 changed files with 28 additions and 4 deletions
@@ -937,7 +937,12 @@ class BleManager private constructor(private val context: Context) {
if (!gatt.requestMtu(247)) {
gatt.discoverServices() // MTU 요청 실패 시 바로 진행
}
onConnectionStateChanged?.invoke(true)
// 2026-07-07 fix: onConnectionStateChanged(true) 를 여기서 호출하면
// TX/RX characteristic 이 아직 null (onServicesDiscovered 미실행) 이라
// 상위 (PiezoMonitoringView) 가 startBatteryPolling 등 TX 를 시작해도
// sendRaw 에서 silent drop → RX 실종 → watchdog timeout → forced reconnect
// 좀비 세션의 원인. 콜백은 isServiceReady=true 시점 (onDescriptorWrite)
// 으로 이동.
}
BluetoothProfile.STATE_DISCONNECTED -> {
logd { "Disconnected from ${gatt.device.address}" }
@@ -1011,6 +1016,7 @@ class BleManager private constructor(private val context: Context) {
handler.post {
if (txCharacteristic != null && rxCharacteristic != null) {
isServiceReady.value = true
onConnectionStateChanged?.invoke(true)
startBatteryPolling()
startWatchdog()
logd { "Service ready (no CCCD)" }
@@ -1021,6 +1027,7 @@ class BleManager private constructor(private val context: Context) {
handler.post {
if (txCharacteristic != null) {
isServiceReady.value = true
onConnectionStateChanged?.invoke(true)
startBatteryPolling()
startWatchdog()
logd { "Service ready (no RX)" }
@@ -1036,6 +1043,10 @@ class BleManager private constructor(private val context: Context) {
if (status == BluetoothGatt.GATT_SUCCESS) {
if (txCharacteristic != null && rxCharacteristic != null) {
isServiceReady.value = true
// 2026-07-07 fix: 여기서 상위에 알림 — TX/RX 준비 완료 시점. 이전에는
// STATE_CONNECTED 즉시 호출해서 아직 characteristic null 인 상태로
// 상위가 TX 시도 → silent drop → 좀비 세션.
onConnectionStateChanged?.invoke(true)
// Connection priority HIGH 요청: peripheral이 수락하면 interval ~15ms로 협상되어
// Central Link Layer ACK 속도 ↑ → FW SoftDevice TX queue 포화 빈도 ↓
val priorityOk = try {
@@ -846,11 +846,17 @@ fun PiezoMonitoringView(appState: AppState) {
}
}
// PiezoMonitoring 이탈 시 imuCollector callback cleanup — 다른 화면 (PlacementGuide 등) 이
// PiezoMonitoring 이탈 시 callback cleanup — 다른 화면 (PlacementGuide, ClinicalHome 등) 이
// 자기 콜백 등록하기 전에 우리 콜백 해제. 다시 진입하면 위 LaunchedEffect 가 재등록.
// 2026-07-07 fix: BleManager 콜백 (onConnectionStateChanged 등) 3개도 정리. 이전에는
// 화면 전환 시 stale 콜백이 남아 새 화면 진입 후에도 이전 화면 로직 (startBatteryPolling
// 등) 이 발동 → GATT operation queue 겹침 → 좀비 세션의 간접 원인.
androidx.compose.runtime.DisposableEffect(Unit) {
onDispose {
bleManager.imuCollector.onComplete = null
bleManager.onConnectionStateChanged = null
bleManager.onUnexpectedDisconnect = null
bleManager.onReconnectionFailed = null
}
}
@@ -162,9 +162,16 @@ fun PlacementGuideView(appState: AppState) {
sweepLastDetected = 0; sweepEverSawAll = false
}
// 화면 벗어날 때 LED OFF
// 화면 벗어날 때 LED OFF + 콜백 정리.
// 2026-07-07 fix: piezoCollector.onMultiChannelComplete / imuCollector.onComplete 는
// PlacementGuide 진입 시 자기 콜백으로 덮어쓰는데, 이탈 시 정리하지 않으면 다음
// 화면 (PiezoMonitoring 등) 의 콜백과 stale reference 가 뒤엉킴.
androidx.compose.runtime.DisposableEffect(Unit) {
onDispose { bleManager.sendLedMode(0) }
onDispose {
bleManager.sendLedMode(0)
bleManager.piezoCollector.onMultiChannelComplete = null
bleManager.imuCollector.onComplete = null
}
}
// Detachment alert (rising edge → 팝업, 재부착 시 자동 해제)