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
@@ -76,6 +76,11 @@ class BleManager private constructor(private val context: Context) {
@Volatile private var lastRxTimestamp: Long = 0 @Volatile private var lastRxTimestamp: Long = 0
private var watchdogTimer: Runnable? = null private var watchdogTimer: Runnable? = null
private val watchdogTimeoutMs: Long = 15000 private val watchdogTimeoutMs: Long = 15000
// 2026-07-07 fix: disconnect 시 취소 위해 Runnable 참조 유지.
// fwFallback: onDescriptorWrite 성공 후 4초 mfv? fallback.
// cccdRetry: onDescriptorWrite 실패 후 500ms retry.
private var fwFallbackTimer: Runnable? = null
private var cccdRetryTimer: Runnable? = null
val discoveredDevices = mutableStateListOf<BleDevice>() val discoveredDevices = mutableStateListOf<BleDevice>()
val isScanning = mutableStateOf(false) val isScanning = mutableStateOf(false)
@@ -708,16 +713,21 @@ class BleManager private constructor(private val context: Context) {
if (silenceMs > watchdogTimeoutMs) { if (silenceMs > watchdogTimeoutMs) {
debugLogger.error("WATCHDOG_TIMEOUT silence=${silenceMs}ms — forcing reconnect") debugLogger.error("WATCHDOG_TIMEOUT silence=${silenceMs}ms — forcing reconnect")
// 2026-07-07 fix: watchdog thread 에서 직접 GATT 정리 + characteristic
// null 하면 UI thread 의 sendRaw 와 race — writeCharacteristic 이 이미
// close 중인 GATT 에 접근. 모든 정리를 UI thread (handler.post) 로 위임하여
// single-threaded 로 실행.
watchdogRunning = false watchdogRunning = false
try {
bluetoothGatt?.disconnect()
bluetoothGatt?.close()
} catch (_: Exception) {}
bluetoothGatt = null
txCharacteristic = null
rxCharacteristic = null
handler.post { handler.post {
try {
bluetoothGatt?.disconnect()
bluetoothGatt?.close()
} catch (_: Exception) {}
bluetoothGatt = null
txCharacteristic = null
rxCharacteristic = null
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
stopBatteryPolling() stopBatteryPolling()
isConnected.value = false isConnected.value = false
isServiceReady.value = false isServiceReady.value = false
@@ -877,6 +887,9 @@ class BleManager private constructor(private val context: Context) {
stopBatteryPolling() stopBatteryPolling()
stopWatchdog() stopWatchdog()
// 2026-07-07 fix: 연결 실패 시 대기 중인 timer 취소.
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
bluetoothGatt = null bluetoothGatt = null
txCharacteristic = null txCharacteristic = null
rxCharacteristic = null rxCharacteristic = null
@@ -948,6 +961,9 @@ class BleManager private constructor(private val context: Context) {
logd { "Disconnected from ${gatt.device.address}" } logd { "Disconnected from ${gatt.device.address}" }
isConnecting.value = false isConnecting.value = false
stopBatteryPolling() stopBatteryPolling()
// 2026-07-07 fix: disconnect 시 대기 중인 timer 취소.
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
bluetoothGatt = null bluetoothGatt = null
txCharacteristic = null txCharacteristic = null
rxCharacteristic = null rxCharacteristic = null
@@ -1059,19 +1075,25 @@ class BleManager private constructor(private val context: Context) {
startBatteryPolling() startBatteryPolling()
startWatchdog() startWatchdog()
sendDeviceInfoQuery() sendDeviceInfoQuery()
// 4초 후에도 펌웨어 미수신이면 mfv? fallback (구 펌웨어는 mid? 미지원 가능성) // 4초 후에도 펌웨어 미수신이면 mfv? fallback (구 펌웨어는 mid? 미지원 가능성).
handler.postDelayed({ // 2026-07-07 fix: disconnect 시 취소되도록 Runnable 참조 유지.
fwFallbackTimer?.let { handler.removeCallbacks(it) }
val fwFallback = Runnable {
if (firmwareVersion.value.isEmpty() && isConnected.value) { if (firmwareVersion.value.isEmpty() && isConnected.value) {
debugLogger.info("FW fallback: sending mfv?") debugLogger.info("FW fallback: sending mfv?")
sendFirmwareVersionQuery() sendFirmwareVersionQuery()
} }
}, 4_000) }
fwFallbackTimer = fwFallback
handler.postDelayed(fwFallback, 4_000)
logd { "Service ready! (CCCD written)" } logd { "Service ready! (CCCD written)" }
} }
} else { } else {
loge { "CCCD write failed with status: $status — retrying in 500ms" } loge { "CCCD write failed with status: $status — retrying in 500ms" }
// GATT 133 등 일시적 오류 → 500ms 후 재시도 // GATT 133 등 일시적 오류 → 500ms 후 재시도.
handler.postDelayed({ // 2026-07-07 fix: disconnect 시 취소되도록 Runnable 참조 유지.
cccdRetryTimer?.let { handler.removeCallbacks(it) }
val cccdRetry = Runnable {
try { try {
val rx = rxCharacteristic val rx = rxCharacteristic
if (rx != null) { if (rx != null) {
@@ -1092,7 +1114,9 @@ class BleManager private constructor(private val context: Context) {
} catch (e: Exception) { } catch (e: Exception) {
connectionError.value = "Notification setup failed (status $status)" connectionError.value = "Notification setup failed (status $status)"
} }
}, 500) }
cccdRetryTimer = cccdRetry
handler.postDelayed(cccdRetry, 500)
} }
} }
} }
@@ -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) { private fun performNirsMeasurement(completion: (Boolean) -> Unit) {
bleManager.onNirsMcjReceived = null // 이전 stale 콜백 해제
bleManager.onNirsMcjReceived = { _ -> bleManager.onNirsMcjReceived = { _ ->
bleManager.onNirsMcjReceived = null // 응답 1회 처리 후 자동 해제
isMeasuring.value = false isMeasuring.value = false
completion(true) completion(true)
} }
@@ -36,7 +41,9 @@ class MeasurementService private constructor(context: Context) {
} }
private fun performPiezoMeasurement(completion: (Boolean) -> Unit) { private fun performPiezoMeasurement(completion: (Boolean) -> Unit) {
bleManager.onPiezoDataReceived = null // 이전 stale 콜백 해제
bleManager.onPiezoDataReceived = { _ -> bleManager.onPiezoDataReceived = { _ ->
bleManager.onPiezoDataReceived = null // 응답 1회 처리 후 자동 해제
isMeasuring.value = false isMeasuring.value = false
completion(true) completion(true)
} }
@@ -45,6 +52,9 @@ class MeasurementService private constructor(context: Context) {
fun stop() { fun stop() {
isMeasuring.value = false isMeasuring.value = false
// 대기 중이던 콜백 취소.
bleManager.onNirsMcjReceived = null
bleManager.onPiezoDataReceived = null
bleManager.sendPiezoStop() bleManager.sendPiezoStop()
} }
} }
@@ -16,6 +16,7 @@ import app.rive.runtime.kotlin.core.Fit
import com.medithings.vesiscan.R import com.medithings.vesiscan.R
import com.medithings.vesiscan.models.UrgencyLevel import com.medithings.vesiscan.models.UrgencyLevel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlin.random.Random import kotlin.random.Random
/** /**
@@ -59,8 +60,8 @@ fun BladdyRiveView(
when (urgency) { when (urgency) {
UrgencyLevel.SAFE -> { UrgencyLevel.SAFE -> {
view.setNumberState("State Machine 1", "state", 0f) view.setNumberState("State Machine 1", "state", 0f)
// Random idle animations every 4 seconds // Random idle animations every 4 seconds — isActive 로 취소 신호 존중.
while (true) { while (isActive) {
delay(4000) delay(4000)
val randomState = listOf(0f, 1f, 5f).random() val randomState = listOf(0f, 1f, 5f).random()
view.setNumberState("State Machine 1", "state", randomState) view.setNumberState("State Machine 1", "state", randomState)
@@ -109,9 +110,9 @@ fun BladdyIdleRiveView(
) )
} }
// Random idle animations every 5 seconds // Random idle animations every 5 seconds — isActive 로 취소 신호 존중.
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (true) { while (isActive) {
delay(5000) delay(5000)
val view = riveView.firstOrNull() ?: continue val view = riveView.firstOrNull() ?: continue
val randomState = listOf(0f, 1f, 5f).random() val randomState = listOf(0f, 1f, 5f).random()
@@ -851,9 +851,13 @@ fun PiezoMonitoringView(appState: AppState) {
// 2026-07-07 fix: BleManager 콜백 (onConnectionStateChanged 등) 3개도 정리. 이전에는 // 2026-07-07 fix: BleManager 콜백 (onConnectionStateChanged 등) 3개도 정리. 이전에는
// 화면 전환 시 stale 콜백이 남아 새 화면 진입 후에도 이전 화면 로직 (startBatteryPolling // 화면 전환 시 stale 콜백이 남아 새 화면 진입 후에도 이전 화면 로직 (startBatteryPolling
// 등) 이 발동 → GATT operation queue 겹침 → 좀비 세션의 간접 원인. // 등) 이 발동 → GATT operation queue 겹침 → 좀비 세션의 간접 원인.
// 2026-07-07 fix v2: piezoCollector.onMultiChannelComplete 도 정리. measure() 안에서
// 대입하는 콜백이 measureScope / channels / 여러 outer state 를 capture 하고 있어
// 이탈 후에도 GC 방해 + 이후 다시 진입 시 stale closure 가 새 상태를 오염시킬 위험.
androidx.compose.runtime.DisposableEffect(Unit) { androidx.compose.runtime.DisposableEffect(Unit) {
onDispose { onDispose {
bleManager.imuCollector.onComplete = null bleManager.imuCollector.onComplete = null
bleManager.piezoCollector.onMultiChannelComplete = null
bleManager.onConnectionStateChanged = null bleManager.onConnectionStateChanged = null
bleManager.onUnexpectedDisconnect = null bleManager.onUnexpectedDisconnect = null
bleManager.onReconnectionFailed = null bleManager.onReconnectionFailed = null
@@ -2739,9 +2743,12 @@ private fun BleDebugPanel(bleManager: BleManager) {
val context = LocalContext.current val context = LocalContext.current
var refreshTick by remember { mutableIntStateOf(0) } var refreshTick by remember { mutableIntStateOf(0) }
// Auto-refresh every 2 seconds // Auto-refresh every 2 seconds.
// 2026-07-07 fix: while(true) → while(isActive). BleDebugPanel Composable 이 화면에서
// 벗어나도 (개발자모드 이탈 등) coroutine 이 계속 살아있어 refreshTick++ 하며
// GC 방해. isActive 로 취소 신호 전달 시 즉시 종료.
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (true) { while (isActive) {
kotlinx.coroutines.delay(2000) kotlinx.coroutines.delay(2000)
refreshTick++ refreshTick++
} }
@@ -117,12 +117,18 @@ fun UrineCameraScreen(
}) })
} }
// 자동 촬영 로직 // 자동 촬영 로직.
// 2026-07-07 fix: currentDetection 은 mutableStateOf delegate — smart cast 안 되어 !! 로 unwrap
// 하는데, coroutine 안에서 첫 null-check 와 !! 사이에 다른 recomposition 이 detection 을
// null 로 만들 수 있음 → NullPointerException. Local val 로 snapshot 후 사용.
LaunchedEffect(currentDetection, isAutoCapturing, capturedBitmap) { LaunchedEffect(currentDetection, isAutoCapturing, capturedBitmap) {
if (capturedBitmap != null || isAutoCapturing) return@LaunchedEffect if (capturedBitmap != null || isAutoCapturing) return@LaunchedEffect
if (currentDetection != null && currentDetection!!.confidence >= 0.90f) { val initialDet = currentDetection
if (initialDet != null && initialDet.confidence >= 0.90f) {
if (detectionStartTime == null) detectionStartTime = System.currentTimeMillis() if (detectionStartTime == null) detectionStartTime = System.currentTimeMillis()
while (currentDetection != null && currentDetection!!.confidence >= 0.90f && !isAutoCapturing && capturedBitmap == null) { while (true) {
val det = currentDetection ?: break
if (det.confidence < 0.90f || isAutoCapturing || capturedBitmap != null) break
val elapsed = System.currentTimeMillis() - (detectionStartTime ?: System.currentTimeMillis()) val elapsed = System.currentTimeMillis() - (detectionStartTime ?: System.currentTimeMillis())
autoCaptureProgress = (elapsed.toFloat() / autoCaptureDelay).coerceIn(0f, 1f) autoCaptureProgress = (elapsed.toFloat() / autoCaptureDelay).coerceIn(0f, 1f)
if (elapsed >= autoCaptureDelay) { doCapture(); break } if (elapsed >= autoCaptureDelay) { doCapture(); break }