From f676bd7c8a1a108a825ab4ff0347309797d18ca6 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Thu, 10 Sep 2026 14:45:22 +0900 Subject: [PATCH] =?UTF-8?q?fix(ble):=20=EB=B0=98=EC=AA=BD=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=E2=80=94=20"=EC=97=B0=EA=B2=B0=EB=90=A8"=EC=9D=B4?= =?UTF-8?q?=EB=9D=BC=20=EB=A7=90=ED=95=98=EA=B3=A0=20=EC=95=84=EB=AC=B4=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=8F=84=20=EC=95=88=20=EC=98=A4?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 현장 재연(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 --- .../java/com/medithings/vesiscan/AppState.kt | 12 +- .../com/medithings/vesiscan/ble/BleManager.kt | 115 +++++++++++++++++- .../ui/views/connection/DeviceScanView.kt | 6 + app/src/main/res/values-ko/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 5 files changed, 132 insertions(+), 5 deletions(-) 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