fix(stability): 코드베이스 진단 HIGH 이슈 6건 fix

병렬 진단 (BLE/thread + Compose lifecycle + null safety) 결과 HIGH
심각도 12건 중 crash 유발 가능성 높고 저비용 고효과인 것 우선 처리.

**H4 — coroutine leak**
- PiezoMonitoringView.kt BleDebugPanel: `while(true) { delay/refresh }` →
  `while(isActive)`. LaunchedEffect 취소 후에도 refreshTick++ 이 계속
  돌아 GC 방해.
- BladdyRiveView.kt 2곳: 동일 패턴 → isActive 로 통일.

**H1 — BleManager postDelayed 미취소**
- `fwFallbackTimer` (4초 mfv? fallback) + `cccdRetryTimer` (500ms CCCD
  재시도) 를 Runnable 참조로 저장. disconnect / GATT_ERROR / watchdog
  timeout 3곳에서 handler.removeCallbacks 로 명시적 취소.
- 이전에는 disconnect 후에도 4초 후 sendFirmwareVersionQuery() 가 발동
  → 이미 close 된 GATT 에 write → silent exception → state 오염.

**H2 — MeasurementService 콜백 leak**
- performNirsMeasurement/performPiezoMeasurement 는 singleton 에서
  콜백 대입만 하고 정리 안 함. Self-clearing lambda 로 응답 1회 처리
  후 자동 null. 시작 시 이전 stale 콜백도 clear.
- stop() 에서도 대기 중 콜백 취소.

**H3 — Watchdog race condition**
- watchdog thread 가 GATT disconnect/close + characteristic null 을
  binder thread 에서 직접 실행 → UI thread 의 sendRaw 와 race.
- 정리 전부를 handler.post 로 UI thread 에 위임 → single-threaded.
- fwFallback/cccdRetry timer 도 여기서 함께 취소.

**H6 — PiezoMonitoringView measure() closure leak**
- DisposableEffect 에 piezoCollector.onMultiChannelComplete 정리 추가.
  measure() 함수 안에서 이 콜백에 measureScope + channels + outer
  state 를 다수 capture → 화면 이탈 후에도 GC 방해 + 재진입 시 stale
  closure 가 새 상태 오염 위험.

**H9 — UrineCameraScreen NPE 위험**
- LaunchedEffect 안 while 루프에서 `currentDetection!!` 이 다른 recompose
  가 detection 을 null 로 만들면 NPE. Local val snapshot 으로 fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 10:40:21 +09:00
parent f4f5407894
commit a10d4b9517
5 changed files with 71 additions and 23 deletions
@@ -27,8 +27,13 @@ class MeasurementService private constructor(context: Context) {
}
}
// 2026-07-07 fix: MeasurementService 는 singleton. 콜백을 대입만 하고 정리하지 않으면
// BleManager 에 stale closure 가 계속 살아있어 이후 응답 이벤트가 이미 완료된
// measurement session 의 completion() 을 다시 호출. Self-clearing lambda 로 처리.
private fun performNirsMeasurement(completion: (Boolean) -> Unit) {
bleManager.onNirsMcjReceived = null // 이전 stale 콜백 해제
bleManager.onNirsMcjReceived = { _ ->
bleManager.onNirsMcjReceived = null // 응답 1회 처리 후 자동 해제
isMeasuring.value = false
completion(true)
}
@@ -36,7 +41,9 @@ class MeasurementService private constructor(context: Context) {
}
private fun performPiezoMeasurement(completion: (Boolean) -> Unit) {
bleManager.onPiezoDataReceived = null // 이전 stale 콜백 해제
bleManager.onPiezoDataReceived = { _ ->
bleManager.onPiezoDataReceived = null // 응답 1회 처리 후 자동 해제
isMeasuring.value = false
completion(true)
}
@@ -45,6 +52,9 @@ class MeasurementService private constructor(context: Context) {
fun stop() {
isMeasuring.value = false
// 대기 중이던 콜백 취소.
bleManager.onNirsMcjReceived = null
bleManager.onPiezoDataReceived = null
bleManager.sendPiezoStop()
}
}