f676bd7c8a
현장 재연(2026-09-10) 10단계를 받아 원인 사슬을 끝까지 따라갔다. 결론은 **반쪽 연결**
(링크는 붙었는데 서비스 탐색·CCCD 미완)이고, 마지막 단계가 제일 나쁘다 — **기기를 15초
길게 눌러 물리적으로 초기화해야만** 복구됐다.
③ 광고 직전/직후에 [저장된 기기] → 링크만 붙고 txCharacteristic = null
④ isServiceReady 가 안 와서 무한 스피너
⑤⑥ 화면은 isConnected 만 봐서 "연결됨"
⑦ txCharacteristic null → sendRawWrite 가 조용히 return → 배터리·IMU 전무
⑧-2 [페어링 삭제] → msr? 가 **안 나갔는데** removeBond() 는 실행
폰: 본드 삭제 ✓ 프로브: 본드 그대로 ✗ ← 비대칭
⑨ 재연결 시 프로브가 옛 LTK 를 요구 → "PIN/passkey 가 올바르지 않다"
⑩ 기기 15초 길게 눌러 초기화해야 복구
## 고친 것 넷
**① 보낼 수 없으면 본드를 지우지 않는다** (⑧-2 → ⑨⑩ 차단)
`canSendCommands`(= isServiceReady && tx != null && gatt != null)를 만들어
`disconnectAndUnbond()` 맨 앞에서 본다. false 면 **아무것도 지우지 않고** 끊고, 기기
초기화를 안내한다. 한쪽만 지운 상태보다 양쪽 다 남은 상태가 훨씬 낫다 — 후자는 그냥 다시
연결하면 된다.
설정탭·임상의 [페어링 삭제] 가 이 함수를 **직접** 부르고 있었다(unbondSmart 만
isServiceReady 를 확인했다). 그래서 방어를 함수 안에 뒀다.
**② "연결됨"의 뜻을 바꿨다** (⑤⑥)
`AppState.isDeviceConnected` 가 `isConnected` → **`isServiceReady`** 를 본다.
"붙었다"가 아니라 **"쓸 수 있다"** 가 사용자에게 의미 있는 상태다.
**③ 반쪽 연결 자가 복구** (④⑤)
링크가 붙은 시점부터 20초 상한을 건다(`armHalfConnectedGuard`). 못 넘기면 끊고, 사용자가
끊은 게 아니면 재연결을 잇는다. connect() 의 타임아웃과 중복이 아니다 — 그쪽은 사용자가
시작한 경로만 덮고, 이쪽은 자동 재연결·autoConnect 로 들어온 연결까지 덮는다. 실측 최악이
15초라 20초로 잡았다.
**④ 쓰기 실패가 보이게** `sendRawWrite` 가 Boolean 을 돌려주고, 실패하면
`TX_FAIL ...` 를 로그에 남긴다. 종전에는 logd 만 찍고 조용했다.
문구는 en/ko 둘 다. `HALF_CONNECTED` 는 "다시 연결합니다",
`UNBOND_UNREACHABLE` 은 15초 길게 누르기까지 구체적으로 안내한다.
테스트 143개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
399 lines
17 KiB
Kotlin
399 lines
17 KiB
Kotlin
package com.medithings.vesiscan
|
|
|
|
import android.content.Context
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.setValue
|
|
import com.medithings.vesiscan.ble.BleManager
|
|
import com.medithings.vesiscan.models.*
|
|
import com.medithings.vesiscan.services.*
|
|
|
|
enum class AppScreen {
|
|
ONBOARDING, CONSENT, REGISTRATION, HOME, SENSOR_SELECT, DEVICE_SCAN,
|
|
PIEZO_PERSONALIZATION,
|
|
PLACEMENT_GUIDE, PIN_SETUP, PIN_ENTRY,
|
|
PIEZO_MONITORING, MEASUREMENT_HISTORY,
|
|
VOIDING_DIARY, REMINDER_SETTINGS,
|
|
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
|
|
CLINICAL_LIVE, // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
|
|
HOSPITAL_MODE, // ★ Dev 모드 한정 — 병원 임상 측정 (주파수×cycle 자동 순회)
|
|
ANCHOR_ALIGN, // ★ Dev 모드 한정 — 부착 위치 정렬 (병원 임상 전 단계)
|
|
FIRMWARE_UPDATE // ★ 2026-08-03 (cloud-mvp 672e679 이식) — VBT 프로브 firmware DFU
|
|
}
|
|
|
|
class AppState(private val context: Context) {
|
|
var currentScreen by mutableStateOf(AppScreen.ONBOARDING)
|
|
|
|
/**
|
|
* DEVICE_SCAN 을 끝내고(연결 성공이든 뒤로가기든) 돌아갈 화면.
|
|
*
|
|
* 기기 연결 화면은 여러 곳에서 불려 오는데, 원래는 `ClinicalSessionStore.inClinicalFlow`
|
|
* 불리언 하나로 "임상 흐름이면 CLINICAL_HOME, 아니면 HOME" 만 갈랐다. 부르는 곳이
|
|
* 셋 이상이 되면 불리언을 더 만들게 되고, 그 순간부터 두 깃발이 어긋나기 시작한다.
|
|
*
|
|
* 그래서 **어디로 돌아갈지를 값으로** 들고 있는다. null 이면 기존 규칙대로 간다 —
|
|
* 기존 호출부는 하나도 건드리지 않았다.
|
|
*
|
|
* 한 번 쓰면 비운다([consumeConnectReturn]). 안 비우면 다음에 다른 경로로 들어온
|
|
* 연결 화면까지 엉뚱한 곳으로 보낸다.
|
|
*/
|
|
/**
|
|
* 정렬로 정한 부착 위치(cm). 병원 임상 모드가 표시·기록에 쓴다.
|
|
*
|
|
* 자세·용량이 바뀌어도 이 위치는 **고정**이라 한 환자 동안 유지된다.
|
|
* 환자가 바뀜면 다시 정렬해야 하므로 병원 모드에서 환자명을 바꿀 때 비운다.
|
|
*/
|
|
var anchorCm: Int? by mutableStateOf(null)
|
|
|
|
/**
|
|
* [anchorCm] 을 **누가** 정했는지. null = 정렬을 거치지 않았다.
|
|
*
|
|
* 정렬은 실제 인체에서 끝까지 안 맞을 수 있어 진행을 막지 않는다. 그래서 위치만
|
|
* 들고 다니면 알고리즘이 고른 자리와 사람이 고른 자리가 한 덩어리로 쌓이고, 나중에
|
|
* 정확도 검증의 모집단을 가려낼 수 없다. **항상 [anchorCm] 과 같이 비우고 같이
|
|
* 넣는다** — 하나만 남으면 그 순간부터 둘이 어긋난다.
|
|
*/
|
|
var anchorBasis: com.medithings.vesiscan.managers.AnchorBasis? by mutableStateOf(null)
|
|
|
|
/**
|
|
* 병원 임상 자세. **정렬 화면과 병원 모드가 같은 값을 봐야 한다.**
|
|
*
|
|
* BV 계산이 이 값을 본다(`supine` → nch=2 빔 clamp 건너뛰기, F83). 화면마다 따로
|
|
* 들고 있으면 같은 프로브·같은 자리에서 **정렬 BV 와 임상 BV 가 다른 식으로**
|
|
* 계산된다 — 실제로 정렬이 `supine` 을 안 넘겨 그렇게 돌고 있었다(2026-09-10).
|
|
*
|
|
* 정렬 화면에는 자세를 고르는 칸이 없다. 병원 모드에서 고른 값을 그대로 쓴다.
|
|
*/
|
|
var clinicalPosture: com.medithings.vesiscan.models.ClinicalPosture by
|
|
mutableStateOf(com.medithings.vesiscan.models.ClinicalPosture.SUPINE)
|
|
|
|
/**
|
|
* 병원 임상 환자명. 화면 상태가 아니라 여기 둔다.
|
|
*
|
|
* 정렬 화면을 다녀오면 [ui.views.clinical.HospitalModeView] 가 파기되어 remember 가
|
|
* 초기화된다. 그러면 돌아와서 이름을 다시 치는 순간 "환자가 바뀌었다"로 보여
|
|
* 방금 맞춘 정렬 결과까지 지워진다. 정렬 화면도 이 이름으로 저장 폴더를 잡는다.
|
|
*/
|
|
var hospitalPatient: String by mutableStateOf("")
|
|
|
|
var connectReturnScreen: AppScreen? = null
|
|
|
|
/** 복귀 화면을 꺼내며 지운다. 없으면 null. */
|
|
fun consumeConnectReturn(): AppScreen? =
|
|
connectReturnScreen.also { connectReturnScreen = null }
|
|
|
|
var user by mutableStateOf(User())
|
|
var sensorMode by mutableStateOf(SensorMode.PIEZO)
|
|
var bladderLevel by mutableStateOf(BladderLevel())
|
|
/**
|
|
* BLE 연결 상태 — **BleManager 실시간 값을 즉시 반영** (2026-08-03 fix · cloud-mvp 9c82a50).
|
|
*
|
|
* 이전: `mutableStateOf(false)` 로 stale snapshot 저장 → BLE 가 끊겨도 홈 화면이
|
|
* "연결됨" 으로 표시되고, Start 누르면 실제 PIEZO_MONITORING 에서
|
|
* "연결 해제" 로 표시되는 불일치 문제.
|
|
*
|
|
* 현재: getter 가 BleManager.isConnected 를 읽음 → Compose 가 자동 재구성.
|
|
* 기존 코드의 setter 는 no-op 로 유지 (호환).
|
|
*/
|
|
/**
|
|
* 화면이 "연결됨"으로 쓰는 값 — **[BleManager.isServiceReady] 를 본다.**
|
|
*
|
|
* 종전에는 `isConnected` 였다. 그런데 링크만 붙고 서비스 탐색·CCCD 가 끝나지 않은
|
|
* **반쪽 연결**이 있다. 그 상태에서 `isConnected` 는 true 이고 화면은 전부 "연결됨"으로
|
|
* 표시했는데 배터리·IMU·측정 어느 것도 오지 않았다 — 사용자는 앱이 멈춘 줄 안다
|
|
* (2026-09-10 현장 재연 ⑤~⑦).
|
|
*
|
|
* **"붙었다"가 아니라 "쓸 수 있다"가 사용자에게 의미 있는 상태다.**
|
|
*/
|
|
var isDeviceConnected: Boolean
|
|
get() = isDemoMode || bleManager.isServiceReady.value
|
|
set(_) { /* no-op · 실제 상태는 BleManager 가 관리 */ }
|
|
var isDemoMode by mutableStateOf(false)
|
|
var isDevMode by mutableStateOf(false)
|
|
var piezoSettings by mutableStateOf(PiezoSettings())
|
|
var catheterCount = mutableIntStateOf(0)
|
|
// Placement 진입 경로 분기용 — Monitoring에서 재진입 시 true, Personalization에서 진입 시 false
|
|
var placementFromMonitoring by mutableStateOf(false)
|
|
|
|
private val storage = UserStorage.getInstance(context)
|
|
private val historyStore = MeasurementHistoryStore.getInstance(context)
|
|
private val notificationService = NotificationService.getInstance(context)
|
|
private val bleManager = BleManager.getInstance(context)
|
|
private val reminderService = ReminderService.getInstance(context)
|
|
|
|
init {
|
|
loadState()
|
|
}
|
|
|
|
fun loadState() {
|
|
user = storage.user
|
|
sensorMode = storage.sensorMode
|
|
bladderLevel = bladderLevel.copy(
|
|
currentLevel = storage.bladderLevel,
|
|
catheterizationThreshold = storage.catheterizationLevel
|
|
)
|
|
piezoSettings = storage.piezoSettings
|
|
catheterCount.intValue = storage.catheterCount
|
|
|
|
currentScreen = when {
|
|
!storage.hasCompletedOnboarding -> AppScreen.ONBOARDING
|
|
!storage.isRegistered -> AppScreen.REGISTRATION
|
|
!storage.hasPin -> AppScreen.PIN_SETUP
|
|
else -> AppScreen.PIN_ENTRY
|
|
}
|
|
}
|
|
|
|
fun completeOnboarding() {
|
|
storage.hasCompletedOnboarding = true
|
|
storage.hasConsented = true
|
|
currentScreen = AppScreen.REGISTRATION
|
|
}
|
|
|
|
fun completeConsent() {
|
|
storage.hasConsented = true
|
|
storage.consentTimestamp = System.currentTimeMillis()
|
|
currentScreen = AppScreen.REGISTRATION
|
|
}
|
|
|
|
fun completeRegistration() {
|
|
storage.user = user
|
|
storage.isRegistered = true
|
|
currentScreen = AppScreen.PIN_SETUP
|
|
}
|
|
|
|
fun goHome() {
|
|
// 회의 결정: 실제 BLE 연결은 HOME에서도 유지. 다음 Start 시 설정/scan skip하고
|
|
// 바로 PiezoMonitoring으로 진입할 수 있도록.
|
|
// Demo 모드는 가짜 연결이므로 isDeviceConnected reset.
|
|
if (isDemoMode) isDeviceConnected = false
|
|
isDemoMode = false
|
|
currentScreen = AppScreen.HOME
|
|
}
|
|
|
|
fun enterDemoMode() {
|
|
isDemoMode = true
|
|
isDeviceConnected = true
|
|
currentScreen = AppScreen.PIEZO_MONITORING
|
|
}
|
|
|
|
fun startFromHome() {
|
|
sensorMode = SensorMode.PIEZO
|
|
storage.sensorMode = SensorMode.PIEZO
|
|
// 회의 결정: 이미 연결된 상태이면 설정/Placement skip하고 바로 도넛차트.
|
|
// 연결되지 않은 상태에서만 DeviceScan부터 일련의 과정 진행.
|
|
currentScreen = if (isDeviceConnected) AppScreen.PIEZO_MONITORING else AppScreen.DEVICE_SCAN
|
|
}
|
|
|
|
fun selectSensor(mode: SensorMode) {
|
|
sensorMode = mode
|
|
storage.sensorMode = mode
|
|
currentScreen = AppScreen.DEVICE_SCAN
|
|
}
|
|
|
|
/**
|
|
* 기기 연결 화면의 **화면 안 뒤로 화살표**.
|
|
*
|
|
* ⚠ 시스템 뒤로가기(MainActivity BackHandler)와 목적지가 다르다 — 그쪽은
|
|
* `inClinicalFlow` 를 보고 CLINICAL_HOME 으로 가는데 여기는 늘 HOME 이다.
|
|
* 기존부터 그랬고 지금 손대면 임상 흐름의 동작이 바뀌므로 그대로 둔다.
|
|
* 다만 복귀처를 지정한 화면([connectReturnScreen])은 두 경로가 같아야 하므로
|
|
* 여기서도 먼저 확인한다.
|
|
*/
|
|
fun backToSensorSelect() {
|
|
consumeConnectReturn()?.let { currentScreen = it; return }
|
|
currentScreen = AppScreen.HOME
|
|
}
|
|
|
|
fun deviceConnected() {
|
|
isDeviceConnected = true
|
|
// 부른 화면이 복귀처를 지정했으면 그리로 (병원 임상 모드 등).
|
|
consumeConnectReturn()?.let { currentScreen = it; return }
|
|
// 임상 모드에서 BLE 연결 시 ClinicalHome으로 복귀 (Personalization 우회)
|
|
if (com.medithings.vesiscan.services.ClinicalSessionStore.inClinicalFlow) {
|
|
currentScreen = AppScreen.CLINICAL_HOME
|
|
return
|
|
}
|
|
currentScreen = AppScreen.PIEZO_PERSONALIZATION
|
|
}
|
|
|
|
fun personalizationComplete() {
|
|
storage.piezoSettings = piezoSettings
|
|
placementFromMonitoring = false
|
|
// 회의 #8: AutoScanInterval을 Personalization 페이지에 통합 — 별도 화면 폐기.
|
|
currentScreen = AppScreen.PLACEMENT_GUIDE
|
|
}
|
|
|
|
/** Persist piezoSettings (called from in-session settings save). */
|
|
fun savePiezoSettingsNow() {
|
|
// Re-assign to trigger the mutableStateOf delegate so any composables
|
|
// observing piezoSettings recompose with the latest value, then persist
|
|
// to disk so the change survives process death.
|
|
piezoSettings = piezoSettings.copy()
|
|
storage.piezoSettings = piezoSettings
|
|
}
|
|
|
|
fun placementComplete() {
|
|
bladderLevel = BladderLevel(currentLevel = 0, catheterizationThreshold = bladderLevel.catheterizationThreshold)
|
|
storage.bladderLevel = 0
|
|
placementFromMonitoring = false
|
|
currentScreen = AppScreen.PIEZO_MONITORING
|
|
}
|
|
|
|
fun enterPlacementFromMonitoring() {
|
|
placementFromMonitoring = true
|
|
currentScreen = AppScreen.PLACEMENT_GUIDE
|
|
}
|
|
|
|
/** Clinical Mode alignment session 진입 — PlacementGuide 로 이동.
|
|
* ClinicalSessionStore.startMeasurement(mode=ALIGNMENT) 가 먼저 호출돼 있어야 함. */
|
|
fun enterAlignmentSession() {
|
|
placementFromMonitoring = false
|
|
currentScreen = AppScreen.PLACEMENT_GUIDE
|
|
}
|
|
|
|
fun backFromPlacementGuide() {
|
|
// 회의 #8: AutoScan을 Personalization으로 통합했으므로 Placement Back은
|
|
// 바로 Personalization으로 (이전 AUTO_SCAN_SETUP 단계 폐기).
|
|
currentScreen = if (placementFromMonitoring) AppScreen.PIEZO_MONITORING
|
|
else AppScreen.PIEZO_PERSONALIZATION
|
|
placementFromMonitoring = false
|
|
}
|
|
|
|
fun pinSet(pin: String) {
|
|
storage.pin = pin
|
|
storage.hasPin = true
|
|
currentScreen = AppScreen.HOME
|
|
}
|
|
|
|
fun pinVerified() {
|
|
currentScreen = AppScreen.HOME
|
|
}
|
|
|
|
fun incrementLevel() {
|
|
// 데모 · 1단계 증가 — 신규 함수로 위임 (cloud-mvp deb7b17 이식).
|
|
val currentVolume = estimatedVolume
|
|
val stepVolume = piezoSettings.maxVolume.toDouble() / BladderLevel.MAX_LEVEL
|
|
updateLevelFromMeasurement(currentVolume + stepVolume, recordHistory = true)
|
|
}
|
|
|
|
/**
|
|
* Phase A · 실측 경로 통합 (GUARDIAN-01, 2026-08-03 · cloud-mvp deb7b17 이식).
|
|
*
|
|
* 이전: PiezoMonitoringView 3곳 bladderLevel 직접 대입 →
|
|
* NotificationService · MeasurementHistoryStore 우회 → 실사용 시 알람 미발생.
|
|
* 이제: 실측/데모 경로 모두 이 함수로 일원화. 알람 · 이력 자동.
|
|
*
|
|
* @param volumeMl 실측 부피 (mL)
|
|
* @param recordHistory 이력 저장 여부 (기본 true · auto-scan 갱신 시 false 로 폭탄 방지)
|
|
*/
|
|
fun updateLevelFromMeasurement(volumeMl: Double, recordHistory: Boolean = true) {
|
|
val previousUrgency = bladderLevel.urgency
|
|
|
|
val maxVol = piezoSettings.maxVolume.toDouble()
|
|
val pct = if (maxVol > 0) (volumeMl / maxVol).coerceAtMost(1.0) else 0.0
|
|
val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(0, BladderLevel.MAX_LEVEL)
|
|
|
|
bladderLevel = bladderLevel.copy(currentLevel = level)
|
|
storage.bladderLevel = bladderLevel.currentLevel
|
|
|
|
if (recordHistory) {
|
|
val record = MeasurementRecord(
|
|
eventType = MeasurementEventType.MEASUREMENT,
|
|
level = bladderLevel.currentLevel,
|
|
estimatedVolume = volumeMl.toInt(),
|
|
urgencyLevel = bladderLevel.urgency,
|
|
fillPercentage = fillPercentage,
|
|
)
|
|
historyStore.addRecord(record)
|
|
}
|
|
|
|
val newUrgency = bladderLevel.urgency
|
|
val urgencyChanged = newUrgency != previousUrgency
|
|
if (urgencyChanged) {
|
|
notificationService.sendUrgencyNotification(newUrgency, volumeMl.toInt())
|
|
}
|
|
|
|
// Phase D (GUARDIAN-01 · 2026-08-03) — ClinicalEventBus emit.
|
|
runCatching {
|
|
com.medithings.vesiscan.telemetry.ClinicalEventBus.emit(
|
|
com.medithings.vesiscan.telemetry.BvEvent(
|
|
ts = System.currentTimeMillis(),
|
|
sessionId = com.medithings.vesiscan.telemetry.TelemetryHash.sessionId(
|
|
bleManager.connectedDeviceName.value.ifEmpty { "unknown" }
|
|
),
|
|
severity = when (newUrgency) {
|
|
com.medithings.vesiscan.models.UrgencyLevel.URGENT ->
|
|
com.medithings.vesiscan.telemetry.Severity.CRITICAL
|
|
com.medithings.vesiscan.models.UrgencyLevel.WARNING ->
|
|
com.medithings.vesiscan.telemetry.Severity.WARN
|
|
else -> com.medithings.vesiscan.telemetry.Severity.INFO
|
|
},
|
|
volumeMl = volumeMl.toInt(),
|
|
urgency = newUrgency,
|
|
bvMethod = com.medithings.vesiscan.managers.GreenZoneConstants.bvMethod.name,
|
|
isTransition = urgencyChanged,
|
|
prevUrgency = previousUrgency.takeIf { urgencyChanged },
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
fun resetLevel() {
|
|
val record = MeasurementRecord(
|
|
eventType = MeasurementEventType.CATHETERIZATION,
|
|
level = bladderLevel.currentLevel,
|
|
estimatedVolume = estimatedVolume,
|
|
urgencyLevel = bladderLevel.urgency,
|
|
fillPercentage = fillPercentage
|
|
)
|
|
historyStore.addRecord(record)
|
|
|
|
val newLevel = bladderLevel.copy()
|
|
newLevel.reset()
|
|
bladderLevel = newLevel
|
|
storage.bladderLevel = 0
|
|
notificationService.cancelAll()
|
|
reminderService.reschedule()
|
|
|
|
if (catheterCount.intValue > 0) {
|
|
catheterCount.intValue--
|
|
storage.catheterCount = catheterCount.intValue
|
|
}
|
|
}
|
|
|
|
fun showHistory() {
|
|
currentScreen = AppScreen.MEASUREMENT_HISTORY
|
|
}
|
|
|
|
fun backToMonitoring() {
|
|
currentScreen = AppScreen.PIEZO_MONITORING
|
|
}
|
|
|
|
fun addCatheterCount(amount: Int = 1) {
|
|
catheterCount.intValue += amount
|
|
storage.catheterCount = catheterCount.intValue
|
|
}
|
|
|
|
fun updateCatheterizationThreshold(threshold: Int) {
|
|
bladderLevel = bladderLevel.copy(catheterizationThreshold = threshold)
|
|
storage.catheterizationLevel = threshold
|
|
}
|
|
|
|
fun savePiezoSettings() {
|
|
storage.piezoSettings = piezoSettings
|
|
}
|
|
|
|
val estimatedVolume: Int
|
|
get() {
|
|
if (BladderLevel.MAX_LEVEL <= 0) return 0
|
|
return (bladderLevel.currentLevel.toDouble() / BladderLevel.MAX_LEVEL * piezoSettings.maxVolume).toInt()
|
|
}
|
|
|
|
val fillPercentage: Double
|
|
get() {
|
|
if (BladderLevel.MAX_LEVEL <= 0) return 0.0
|
|
return bladderLevel.currentLevel.toDouble() / BladderLevel.MAX_LEVEL * 100.0
|
|
}
|
|
}
|