diff --git a/app/src/main/java/com/medithings/vesiscan/AppState.kt b/app/src/main/java/com/medithings/vesiscan/AppState.kt index d98524a..cd29536 100644 --- a/app/src/main/java/com/medithings/vesiscan/AppState.kt +++ b/app/src/main/java/com/medithings/vesiscan/AppState.kt @@ -96,8 +96,18 @@ class AppState(private val context: Context) { * 현재: getter 가 BleManager.isConnected 를 읽음 → Compose 가 자동 재구성. * 기존 코드의 setter 는 no-op 로 유지 (호환). */ + /** + * 화면이 "연결됨"으로 쓰는 값 — **[BleManager.isServiceReady] 를 본다.** + * + * 종전에는 `isConnected` 였다. 그런데 링크만 붙고 서비스 탐색·CCCD 가 끝나지 않은 + * **반쪽 연결**이 있다. 그 상태에서 `isConnected` 는 true 이고 화면은 전부 "연결됨"으로 + * 표시했는데 배터리·IMU·측정 어느 것도 오지 않았다 — 사용자는 앱이 멈춘 줄 안다 + * (2026-09-10 현장 재연 ⑤~⑦). + * + * **"붙었다"가 아니라 "쓸 수 있다"가 사용자에게 의미 있는 상태다.** + */ var isDeviceConnected: Boolean - get() = isDemoMode || bleManager.isConnected.value + get() = isDemoMode || bleManager.isServiceReady.value set(_) { /* no-op · 실제 상태는 BleManager 가 관리 */ } var isDemoMode by mutableStateOf(false) var isDevMode by mutableStateOf(false) diff --git a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt index 0559c5d..edff81a 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -464,6 +464,18 @@ class BleManager private constructor(private val context: Context) { /** 마지막으로 연결을 끊은 시각. 쿨다운 계산에만 쓴다. */ @Volatile private var lastDisconnectAtMs = 0L + /** + * **명령을 실제로 보낼 수 있는가.** [isConnected] 와 다르다. + * + * 링크만 붙고 서비스 탐색·CCCD 가 끝나지 않은 **반쪽 연결**이 있다. 그 상태에서는 + * `txCharacteristic` 이 null 이라 `sendRawWrite` 가 조용히 빠져나가고, 배터리·IMU·측정 + * 어느 것도 오지 않는다. 화면은 [isConnected] 만 보고 "연결됨"이라고 했다. + * + * 광고 직전/직후에 [저장된 기기] 를 누르면 재연된다(2026-09-10 현장). + */ + val canSendCommands: Boolean + get() = isServiceReady.value && txCharacteristic != null && bluetoothGatt != null + /** * 쿨다운이 끝나면 연결할 예약. 두 번 예약되면 GATT 가 두 개 열린다. * @@ -472,6 +484,54 @@ class BleManager private constructor(private val context: Context) { */ private var pendingConnect: Runnable? = null + private var halfConnGuard: Runnable? = null + + /** + * 링크가 붙은 뒤 [isServiceReady] 까지의 상한. 못 넘기면 **끊는다.** + * + * 반쪽 연결을 살려 두면 화면은 "연결됨"인데 아무 데이터도 오지 않고, 그 상태에서 + * [페어링 삭제] 를 누르면 본드가 비대칭이 되어 기기를 물리적으로 초기화해야 한다. + * 그러니 **빨리 끊고 다시 붙는 편이 낫다.** + * + * [connect] 의 타임아웃과 중복이 아니다 — 그쪽은 사용자가 시작한 경로만 덮고, 이쪽은 + * 자동 재연결·autoConnect 로 들어온 연결까지 덮는다. + */ + private fun armHalfConnectedGuard(gatt: BluetoothGatt) { + halfConnGuard?.let { handler.removeCallbacks(it) } + val r = Runnable { + halfConnGuard = null + if (gatt !== bluetoothGatt) return@Runnable + if (isServiceReady.value) return@Runnable + debugLogger.error( + "HALF_CONNECTED 링크는 붙었는데 서비스가 준비되지 않았습니다 " + + "(${SERVICE_READY_GUARD_MS}ms) — 끊습니다" + ) + commandQueue.clear("half-connected") + try { gatt.disconnect(); gatt.close() } catch (_: Exception) {} + if (gatt === bluetoothGatt) { + bluetoothGatt = null + txCharacteristic = null + rxCharacteristic = null + } + isConnected.value = false + isServiceReady.value = false + isConnecting.value = false + connectionError.value = "HALF_CONNECTED" + // 사용자가 끊은 게 아니면 계속 되살린다. + if (!isUserDisconnect && lastConnectedAddress != null) scheduleAutoReconnect() + } + halfConnGuard = r + handler.postDelayed(r, SERVICE_READY_GUARD_MS) + } + + private fun clearHalfConnectedGuard() { + halfConnGuard?.let { handler.removeCallbacks(it) } + halfConnGuard = null + } + + /** 링크 수립 후 서비스 준비까지 허용 시간. 실측 최악이 15초였다(2026-09-10). */ + private val SERVICE_READY_GUARD_MS = 20_000L + private fun isStaleGatt(gatt: BluetoothGatt, where: String): Boolean { val current = bluetoothGatt ?: return false if (gatt === current) return false @@ -733,6 +793,7 @@ class BleManager private constructor(private val context: Context) { fun disconnect() { lastDisconnectAtMs = System.currentTimeMillis() + clearHalfConnectedGuard() // 쿨다운 예약을 버린다. 안 버리면 끊은 뒤 600ms 만에 스스로 다시 연결된다. pendingConnect?.let { handler.removeCallbacks(it); pendingConnect = null } isUserDisconnect = true @@ -750,7 +811,34 @@ class BleManager private constructor(private val context: Context) { com.medithings.vesiscan.services.BleForegroundService.stop(context) } + /** + * 양쪽 본드를 지우고 끊는다 — 프로브에 `msr?`(본드 삭제 + 재부팅), 폰에 `removeBond()`. + * + * ## ⚠ 보낼 수 없으면 **폰 쪽도 지우지 않는다** + * 반쪽 연결에서는 `msr?` 가 한 바이트도 안 나간다([canSendCommands]). 그런데 종전에는 + * 그걸 모르고 `removeBond()` 를 실행해 **본드가 비대칭**이 됐다: + * + * 폰: 본드 삭제 ✓ 프로브: 본드 그대로 ✗ + * + * 다음 연결에서 프로브가 옛 LTK 로 암호화를 요구하는데 폰엔 키가 없어 "PIN 또는 + * passkey 가 올바르지 않습니다"로 거부된다. **앱으로는 복구가 안 된다** — 프로브를 + * 15초 길게 눌러 본드를 초기화해야 한다(2026-09-10 현장에서 그렇게 복구했다). + * + * 그래서 보낼 수 없으면 **아무것도 지우지 않고** 연결만 끊고, 사용자에게 기기 초기화를 + * 안내한다. 한쪽만 지운 상태보다 양쪽 다 남은 상태가 훨씬 낫다 — 후자는 그냥 다시 + * 연결하면 된다. + */ fun disconnectAndUnbond() { + if (!canSendCommands) { + debugLogger.error( + "UNBOND_REFUSED canSend=false serviceReady=${isServiceReady.value} " + + "tx=${txCharacteristic != null} gatt=${bluetoothGatt != null} — " + + "msr? 를 보낼 수 없어 폰 본드도 지우지 않습니다(비대칭 방지)" + ) + connectionError.value = "UNBOND_UNREACHABLE" + disconnect() + return + } lastDisconnectAtMs = System.currentTimeMillis() pendingConnect?.let { handler.removeCallbacks(it); pendingConnect = null } isUserDisconnect = true @@ -877,16 +965,26 @@ class BleManager private constructor(private val context: Context) { ) /** 실제 GATT write — CommandQueue 내부에서만 호출. 외부는 sendRaw() 사용. */ - private fun sendRawWrite(data: ByteArray) { + /** + * @return 쓰기 요청이 **실제로 스택에 들어갔는가.** false = 한 바이트도 안 나갔다. + * + * 반환값을 만든 이유는 [disconnectAndUnbond] 다. 반쪽 연결(링크는 붙었는데 서비스 + * 미완)에서는 `txCharacteristic` 이 null 이라 여기서 조용히 빠져나갔고, 그걸 모르고 + * 폰 쪽 본드만 지워 **프로브와 본드가 비대칭**이 됐다. 그 상태는 프로브를 물리적으로 + * 초기화(15초 길게 누르기)해야만 복구된다 — 2026-09-10 현장 재연으로 확인. + */ + private fun sendRawWrite(data: ByteArray): Boolean { val characteristic = txCharacteristic val gatt = bluetoothGatt if (characteristic == null) { loge { "sendRawWrite: txCharacteristic is null!" } - return + debugLogger.error("TX_FAIL txCharacteristic=null — 명령이 나가지 않았습니다") + return false } if (gatt == null) { loge { "sendRawWrite: bluetoothGatt is null!" } - return + debugLogger.error("TX_FAIL bluetoothGatt=null — 명령이 나가지 않았습니다") + return false } val cmdPreview = if (data.size >= 4) String(data, 0, 3, Charsets.US_ASCII) else "?" val cmdDetail = when (cmdPreview) { @@ -908,9 +1006,11 @@ class BleManager private constructor(private val context: Context) { } logd { "sendRawWrite: $cmdPreview (${data.size} bytes)" } debugLogger.tx("$cmdPreview $cmdDetail", data.size) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val result = gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) logd { "sendRawWrite result: $result" } + // API 33+ 는 상태 코드다 — 0(SUCCESS) 만 실제로 들어간 것이다. + result == BluetoothStatusCodes.SUCCESS } else { @Suppress("DEPRECATION") characteristic.value = data @@ -918,6 +1018,7 @@ class BleManager private constructor(private val context: Context) { @Suppress("DEPRECATION") val ok = gatt.writeCharacteristic(characteristic) logd { "sendRawWrite success: $ok" } + ok } } @@ -1618,6 +1719,10 @@ class BleManager private constructor(private val context: Context) { when (newState) { BluetoothProfile.STATE_CONNECTED -> { logd { "Connected to ${gatt.device.address}" } + // 반쪽 연결 감시. 링크가 붙은 시점부터 재는 상한이다 — + // connect() 의 타임아웃은 사용자가 시작한 경로에만 걸리므로, 재연결· + // 자동연결로 들어온 반쪽 연결은 아무도 보지 않았다. + armHalfConnectedGuard(gatt) // **이 연결을 현재 GATT 로 못박는다.** // // 재연결 경로는 `bluetoothGatt = connectGatt(...)` 로 대입하지만, @@ -1759,6 +1864,7 @@ class BleManager private constructor(private val context: Context) { handler.post { if (txCharacteristic != null && rxCharacteristic != null) { isServiceReady.value = true + clearHalfConnectedGuard() onConnectionStateChanged?.invoke(true) startBatteryPolling() startRssiPolling() @@ -1771,6 +1877,7 @@ class BleManager private constructor(private val context: Context) { handler.post { if (txCharacteristic != null) { isServiceReady.value = true + clearHalfConnectedGuard() onConnectionStateChanged?.invoke(true) startBatteryPolling() startRssiPolling() diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/connection/DeviceScanView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/connection/DeviceScanView.kt index 0ae8691..3a0482d 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/connection/DeviceScanView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/connection/DeviceScanView.kt @@ -263,6 +263,10 @@ fun DeviceScanView(appState: AppState) { val isBondConflict = curError.startsWith("BOND_CONFLICT:") && curBondAddr != null val isDeviceUnresponsive = curError.startsWith("DEVICE_UNRESPONSIVE:") val isReconnectGaveUp = curError == "RECONNECT_GAVE_UP" + // 반쪽 연결 — 링크는 붙었는데 서비스가 준비되지 않아 앱이 끊은 경우. + val isHalfConnected = curError == "HALF_CONNECTED" + // 본드 비대칭을 막으려고 페어링 삭제를 거부한 경우. 기기 초기화가 필요하다. + val isUnbondUnreachable = curError == "UNBOND_UNREACHABLE" Column( modifier = Modifier .fillMaxWidth() @@ -278,6 +282,8 @@ fun DeviceScanView(appState: AppState) { isBondConflict -> stringResource(R.string.ble_error_bond_conflict) isDeviceUnresponsive -> stringResource(R.string.ble_error_device_unresponsive) isReconnectGaveUp -> stringResource(R.string.ble_error_reconnect_gave_up) + isHalfConnected -> stringResource(R.string.ble_error_half_connected) + isUnbondUnreachable -> stringResource(R.string.ble_error_unbond_unreachable) else -> curError } Text(displayText, color = Color.White, fontSize = 13.sp, modifier = Modifier.weight(1f)) diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 83587c1..e0188e7 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -371,6 +371,8 @@ 측정 모드 진입 (Dev) 저장된 기기 연결이 끝나지 않았습니다. 기기를 다시 눌러 주세요. + 연결이 끝까지 되지 않았습니다 — 기기가 응답했지만 준비를 마치지 못했습니다. 다시 연결합니다. + 페어링을 삭제할 수 없습니다: 기기에 명령이 닿지 않습니다. 기기 버튼을 15초 길게 눌러 초기화한 뒤, 저장된 기기를 삭제하고 다시 페어링하세요. 새 기기 권한 허용 검색 중지 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 803a2b1..ec414f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -371,6 +371,8 @@ Enter Measurement Mode (Dev) Saved Devices Connection did not finish. Tap the device again. + Connection was incomplete — the device answered but did not finish setup. Reconnecting. + Cannot delete pairing: the device is not reachable. Hold the device button for 15 seconds to reset it, then delete the saved device and pair again. New Devices Grant Permissions Stop Scan