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 582b571..8dd30df 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -426,6 +426,15 @@ class BleManager private constructor(private val context: Context) { } } + /** + * 연결 타임아웃 — `connectGatt` 부터 [isServiceReady] 까지. + * + * 10초였는데 늘렸다. 링크가 붙은 뒤 서비스 준비까지 **15초**가 걸린 실측이 있어 + * (2026-09-10 12:29 로그) 10초로 자르면 정상 연결을 실패로 끊는다. 타임아웃이 보는 + * 신호를 `isConnected` → `isServiceReady` 로 바꾸면서 같이 올렸다. + */ + private val CONNECT_TIMEOUT_MS = 20_000L + // Connection fun connect(bleDevice: BleDevice) { stopScan() @@ -480,11 +489,33 @@ class BleManager private constructor(private val context: Context) { return } - // Connection timeout + // ── 연결 타임아웃 ─────────────────────────────────────────────────── + // **[isServiceReady] 를 본다. [isConnected] 가 아니다.** + // + // 종전에는 `!isConnected.value` 였다. 그러면 링크는 붙었는데(STATE_CONNECTED) + // 서비스 탐색·CCCD 구독이 끝나지 않는 상태가 타임아웃에서 **면제**된다. 화면은 + // `isServiceReady` 나 `connectionError` 중 하나를 기다리므로(DeviceScanView), + // 둘 다 안 오면 스피너가 영원히 돌고 다른 기기 행까지 전부 비활성이 된다. + // + // 그 상태는 실제로 생긴다. 2026-09-10 12:29 로그에서 연결 직후 **15초 동안** + // 모든 명령이 `CMDQ drop ... timeout` 이었다(RX 0). 그때는 풀렸지만 안 풀리면 + // 무한 로딩이다 — 연결 해제 직후 재연결에서 특히 잘 난다. + // + // 그래서 **화면이 기다리는 것과 같은 신호**로 본다. connectionTimer?.let { handler.removeCallbacks(it) } val timeout = Runnable { - if (!isConnected.value) { - logw { "Connection timeout" } + if (!isServiceReady.value) { + logw { "Connection timeout (isConnected=${isConnected.value} serviceReady=false)" } + debugLogger.warn( + "CONNECT_TIMEOUT after ${CONNECT_TIMEOUT_MS}ms " + + "isConnected=${isConnected.value} serviceReady=false" + ) + // 링크가 붙어 있을 수 있다 — 끊고 닫아야 다음 시도가 깨끗하다. 종전에는 + // 에러만 띄우고 GATT 를 살려 둬서, 다시 누르면 같은 반쪽 연결을 물고 갔다. + isUserDisconnect = true + cancelAutoReconnect() + stopWatchdog() + commandQueue.clear("connect-timeout") bluetoothGatt?.let { gatt -> try { gatt.disconnect() @@ -492,14 +523,20 @@ class BleManager private constructor(private val context: Context) { } catch (_: Exception) {} } bluetoothGatt = null + txCharacteristic = null + rxCharacteristic = null + isConnected.value = false + isServiceReady.value = false + isConnecting.value = false connectionError.value = "Connection timed out. Try again." if (idx >= 0 && idx < discoveredDevices.size) { - discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = false) + discoveredDevices[idx] = + discoveredDevices[idx].copy(isConnected = false, isConnecting = false) } } } connectionTimer = timeout - handler.postDelayed(timeout, 10000) + handler.postDelayed(timeout, CONNECT_TIMEOUT_MS) } /** @@ -1346,6 +1383,35 @@ class BleManager private constructor(private val context: Context) { bluetoothGatt = result.device.connectGatt( context, false, gattCallback, BluetoothDevice.TRANSPORT_LE ) + // 재연결에도 **서비스 준비 타임아웃**을 건다. + // + // 종전에는 아무 상한이 없었다. 링크만 붙고(isConnected=true) 서비스 + // 탐색·CCCD 가 끝나지 않으면: ① 아래 8초 스캔 타임아웃이 isConnected 를 + // 보고 "붙었다"며 재시도를 멈추고, ② watchdog 은 isServiceReady 뒤에만 + // 시작하므로 돌지 않는다. 결과는 **아무도 보지 않는 반쪽 연결**이다 — + // 앱은 연결됐다고 표시하는데 명령이 전부 timeout 난다. + connectionTimer?.let { handler.removeCallbacks(it) } + val rcTimeout = Runnable { + if (!isServiceReady.value) { + debugLogger.warn( + "RECONNECT_SERVICE_TIMEOUT after ${CONNECT_TIMEOUT_MS}ms " + + "isConnected=${isConnected.value} — 끊고 다시 시도" + ) + commandQueue.clear("reconnect-service-timeout") + bluetoothGatt?.let { g -> + try { g.disconnect(); g.close() } catch (_: Exception) {} + } + bluetoothGatt = null + txCharacteristic = null + rxCharacteristic = null + isConnected.value = false + isServiceReady.value = false + // 사용자가 끊은 게 아니다 — 계속 되살려야 한다. + scheduleAutoReconnect() + } + } + connectionTimer = rcTimeout + handler.postDelayed(rcTimeout, CONNECT_TIMEOUT_MS) } } } 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 f5f6d95..0ae8691 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 @@ -47,6 +47,12 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +/** + * 스피너를 강제로 푸는 상한. BleManager 의 연결 타임아웃(20초)보다 길어야 한다 — + * 짧으면 정상 연결 중인 시도를 사용자가 다시 눌러 GATT 가 두 번 열린다. + */ +private const val SPINNER_TIMEOUT_MS = 25_000L + /** 저장된 기기 삭제 다이얼로그 상태 (2026-08-04 · BLE bonding 대칭성 fix). */ private enum class UnpairPhase { Confirm, Reconnecting, OfflineWarning } @@ -144,6 +150,27 @@ fun DeviceScanView(appState: AppState) { if (connectionError != null) connectingDeviceAddress = null } + // ── 스피너 보험 ───────────────────────────────────────────────────────── + // BleManager 가 신호를 못 주는 경로가 하나라도 남으면 이 화면은 **영원히 잠긴다** — + // 누른 행은 스피너가 돌고, `enabled = connectingDeviceAddress == null` 때문에 다른 + // 행까지 전부 안 눌린다. 실제로 연결 해제 뒤 [저장된 기기] 를 누르면 그렇게 됐다 + // (2026-09-10). 원인은 BleManager 쪽에서 고쳤지만, 화면에도 상한을 둔다. + // + // BleManager 의 타임아웃(20초)보다 **길게** 잡는다. 짧으면 이쪽이 먼저 풀려서 정상 + // 연결 중인 시도를 사용자가 다시 누를 수 있고, 그러면 GATT 가 두 번 열린다. + LaunchedEffect(connectingDeviceAddress) { + val addr = connectingDeviceAddress ?: return@LaunchedEffect + kotlinx.coroutines.delay(SPINNER_TIMEOUT_MS) + // 그 사이 성공·실패로 바뀌었으면 건드리지 않는다. + if (connectingDeviceAddress == addr && !isServiceReady) { + connectingDeviceAddress = null + if (bleManager.connectionError.value == null) { + bleManager.connectionError.value = + context.getString(R.string.device_scan_connect_stuck_err) + } + } + } + DisposableEffect(Unit) { onDispose { bleManager.stopScan() } } // 페어링 안내 다이얼로그 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 567b73c..83587c1 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -370,6 +370,7 @@ 기기를 다시 켜보세요 측정 모드 진입 (Dev) 저장된 기기 + 연결이 끝나지 않았습니다. 기기를 다시 눌러 주세요. 새 기기 권한 허용 검색 중지 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a1e3ef..803a2b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -370,6 +370,7 @@ Try restarting the device Enter Measurement Mode (Dev) Saved Devices + Connection did not finish. Tap the device again. New Devices Grant Permissions Stop Scan