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:
@@ -76,6 +76,11 @@ class BleManager private constructor(private val context: Context) {
|
||||
@Volatile private var lastRxTimestamp: Long = 0
|
||||
private var watchdogTimer: Runnable? = null
|
||||
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 isScanning = mutableStateOf(false)
|
||||
@@ -708,16 +713,21 @@ class BleManager private constructor(private val context: Context) {
|
||||
if (silenceMs > watchdogTimeoutMs) {
|
||||
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
|
||||
try {
|
||||
bluetoothGatt?.disconnect()
|
||||
bluetoothGatt?.close()
|
||||
} catch (_: Exception) {}
|
||||
bluetoothGatt = null
|
||||
txCharacteristic = null
|
||||
rxCharacteristic = null
|
||||
|
||||
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()
|
||||
isConnected.value = false
|
||||
isServiceReady.value = false
|
||||
@@ -877,6 +887,9 @@ class BleManager private constructor(private val context: Context) {
|
||||
|
||||
stopBatteryPolling()
|
||||
stopWatchdog()
|
||||
// 2026-07-07 fix: 연결 실패 시 대기 중인 timer 취소.
|
||||
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
|
||||
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
|
||||
bluetoothGatt = null
|
||||
txCharacteristic = null
|
||||
rxCharacteristic = null
|
||||
@@ -948,6 +961,9 @@ class BleManager private constructor(private val context: Context) {
|
||||
logd { "Disconnected from ${gatt.device.address}" }
|
||||
isConnecting.value = false
|
||||
stopBatteryPolling()
|
||||
// 2026-07-07 fix: disconnect 시 대기 중인 timer 취소.
|
||||
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
|
||||
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
|
||||
bluetoothGatt = null
|
||||
txCharacteristic = null
|
||||
rxCharacteristic = null
|
||||
@@ -1059,19 +1075,25 @@ class BleManager private constructor(private val context: Context) {
|
||||
startBatteryPolling()
|
||||
startWatchdog()
|
||||
sendDeviceInfoQuery()
|
||||
// 4초 후에도 펌웨어 미수신이면 mfv? fallback (구 펌웨어는 mid? 미지원 가능성)
|
||||
handler.postDelayed({
|
||||
// 4초 후에도 펌웨어 미수신이면 mfv? fallback (구 펌웨어는 mid? 미지원 가능성).
|
||||
// 2026-07-07 fix: disconnect 시 취소되도록 Runnable 참조 유지.
|
||||
fwFallbackTimer?.let { handler.removeCallbacks(it) }
|
||||
val fwFallback = Runnable {
|
||||
if (firmwareVersion.value.isEmpty() && isConnected.value) {
|
||||
debugLogger.info("FW fallback: sending mfv?")
|
||||
sendFirmwareVersionQuery()
|
||||
}
|
||||
}, 4_000)
|
||||
}
|
||||
fwFallbackTimer = fwFallback
|
||||
handler.postDelayed(fwFallback, 4_000)
|
||||
logd { "Service ready! (CCCD written)" }
|
||||
}
|
||||
} else {
|
||||
loge { "CCCD write failed with status: $status — retrying in 500ms" }
|
||||
// GATT 133 등 일시적 오류 → 500ms 후 재시도
|
||||
handler.postDelayed({
|
||||
// GATT 133 등 일시적 오류 → 500ms 후 재시도.
|
||||
// 2026-07-07 fix: disconnect 시 취소되도록 Runnable 참조 유지.
|
||||
cccdRetryTimer?.let { handler.removeCallbacks(it) }
|
||||
val cccdRetry = Runnable {
|
||||
try {
|
||||
val rx = rxCharacteristic
|
||||
if (rx != null) {
|
||||
@@ -1092,7 +1114,9 @@ class BleManager private constructor(private val context: Context) {
|
||||
} catch (e: Exception) {
|
||||
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) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import app.rive.runtime.kotlin.core.Fit
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.models.UrgencyLevel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
@@ -59,8 +60,8 @@ fun BladdyRiveView(
|
||||
when (urgency) {
|
||||
UrgencyLevel.SAFE -> {
|
||||
view.setNumberState("State Machine 1", "state", 0f)
|
||||
// Random idle animations every 4 seconds
|
||||
while (true) {
|
||||
// Random idle animations every 4 seconds — isActive 로 취소 신호 존중.
|
||||
while (isActive) {
|
||||
delay(4000)
|
||||
val randomState = listOf(0f, 1f, 5f).random()
|
||||
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) {
|
||||
while (true) {
|
||||
while (isActive) {
|
||||
delay(5000)
|
||||
val view = riveView.firstOrNull() ?: continue
|
||||
val randomState = listOf(0f, 1f, 5f).random()
|
||||
|
||||
+9
-2
@@ -851,9 +851,13 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
// 2026-07-07 fix: BleManager 콜백 (onConnectionStateChanged 등) 3개도 정리. 이전에는
|
||||
// 화면 전환 시 stale 콜백이 남아 새 화면 진입 후에도 이전 화면 로직 (startBatteryPolling
|
||||
// 등) 이 발동 → GATT operation queue 겹침 → 좀비 세션의 간접 원인.
|
||||
// 2026-07-07 fix v2: piezoCollector.onMultiChannelComplete 도 정리. measure() 안에서
|
||||
// 대입하는 콜백이 measureScope / channels / 여러 outer state 를 capture 하고 있어
|
||||
// 이탈 후에도 GC 방해 + 이후 다시 진입 시 stale closure 가 새 상태를 오염시킬 위험.
|
||||
androidx.compose.runtime.DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
bleManager.imuCollector.onComplete = null
|
||||
bleManager.piezoCollector.onMultiChannelComplete = null
|
||||
bleManager.onConnectionStateChanged = null
|
||||
bleManager.onUnexpectedDisconnect = null
|
||||
bleManager.onReconnectionFailed = null
|
||||
@@ -2739,9 +2743,12 @@ private fun BleDebugPanel(bleManager: BleManager) {
|
||||
val context = LocalContext.current
|
||||
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) {
|
||||
while (true) {
|
||||
while (isActive) {
|
||||
kotlinx.coroutines.delay(2000)
|
||||
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) {
|
||||
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()
|
||||
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())
|
||||
autoCaptureProgress = (elapsed.toFloat() / autoCaptureDelay).coerceIn(0f, 1f)
|
||||
if (elapsed >= autoCaptureDelay) { doCapture(); break }
|
||||
|
||||
Reference in New Issue
Block a user