fix(ble): 연결 타임아웃이 isConnected 를 봐서 무한 로딩이 났다
연결 → 연결 해제 → 메인 → [기기 연결] → [저장된 기기] 를 누르면 스피너가 영원히 돌고,
다른 기기 행까지 전부 비활성이 됐다(enabled = connectingDeviceAddress == null).
## 원인
화면이 스피너를 끄는 신호는 **둘뿐**이다 — `isServiceReady` 또는 `connectionError`.
그런데 10초 타임아웃이 보던 것은 `isConnected` 였다:
if (!isConnected.value) { connectionError.value = "Connection timed out..." }
링크는 붙었는데(STATE_CONNECTED) 서비스 탐색·CCCD 구독이 끝나지 않으면 **타임아웃이
면제된다.** 에러도 성공도 안 오니 화면이 영원히 잠긴다.
그 상태가 실제로 생긴다. 2026-09-10 12:29 로그에서 연결 직후 **15초 동안** 모든 명령이
`CMDQ drop ... timeout` 이었다(RX 0). 그때는 풀렸지만 안 풀리면 그대로다 — 연결 해제 뒤
재연결에서 특히 잘 난다.
## 고친 것 넷
**① 타임아웃이 보는 신호** `!isConnected` → `!isServiceReady`. 화면이 기다리는 것과 같아야
한다. 같이 10초 → **20초**로 올렸다 — 위 실측이 15초라 10초로 자르면 정상 연결을 끊는다.
**② 타임아웃 후 정리** 종전에는 에러만 띄우고 GATT 를 살려 뒀다. 다시 누르면 같은 반쪽
연결을 물고 간다. disconnect + close + 상태 리셋까지 한다.
**③ 화면 쪽 보험** DeviceScanView 에 25초 상한. BleManager 가 신호를 못 주는 경로가 하나라도
남으면 이 화면은 영원히 잠기므로, 원인을 고쳤어도 상한은 둔다. BleManager 의 20초보다
**길게** 잡았다 — 짧으면 정상 연결 중인 시도를 다시 눌러 GATT 가 두 번 열린다.
**④ 재연결 경로에도 같은 구멍** 재연결의 connectGatt 에는 상한이 **아예 없었다.** 그리고
watchdog 은 `isServiceReady` 뒤에만 시작하고, 8초 스캔 타임아웃은 `isConnected` 를 보고
"붙었다"며 재시도를 멈춘다. 결과는 **아무도 보지 않는 반쪽 연결** — 앱은 연결됐다고
표시하는데 명령이 전부 timeout 난다. 같은 20초 상한을 걸고 실패하면 재연결을 잇는다.
문구는 en/ko 둘 다 추가했다.
테스트 143개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
// Connection
|
||||||
fun connect(bleDevice: BleDevice) {
|
fun connect(bleDevice: BleDevice) {
|
||||||
stopScan()
|
stopScan()
|
||||||
@@ -480,11 +489,33 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
return
|
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) }
|
connectionTimer?.let { handler.removeCallbacks(it) }
|
||||||
val timeout = Runnable {
|
val timeout = Runnable {
|
||||||
if (!isConnected.value) {
|
if (!isServiceReady.value) {
|
||||||
logw { "Connection timeout" }
|
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 ->
|
bluetoothGatt?.let { gatt ->
|
||||||
try {
|
try {
|
||||||
gatt.disconnect()
|
gatt.disconnect()
|
||||||
@@ -492,14 +523,20 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
bluetoothGatt = null
|
bluetoothGatt = null
|
||||||
|
txCharacteristic = null
|
||||||
|
rxCharacteristic = null
|
||||||
|
isConnected.value = false
|
||||||
|
isServiceReady.value = false
|
||||||
|
isConnecting.value = false
|
||||||
connectionError.value = "Connection timed out. Try again."
|
connectionError.value = "Connection timed out. Try again."
|
||||||
if (idx >= 0 && idx < discoveredDevices.size) {
|
if (idx >= 0 && idx < discoveredDevices.size) {
|
||||||
discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = false)
|
discoveredDevices[idx] =
|
||||||
|
discoveredDevices[idx].copy(isConnected = false, isConnecting = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connectionTimer = timeout
|
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(
|
bluetoothGatt = result.device.connectGatt(
|
||||||
context, false, gattCallback, BluetoothDevice.TRANSPORT_LE
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스피너를 강제로 푸는 상한. BleManager 의 연결 타임아웃(20초)보다 길어야 한다 —
|
||||||
|
* 짧으면 정상 연결 중인 시도를 사용자가 다시 눌러 GATT 가 두 번 열린다.
|
||||||
|
*/
|
||||||
|
private const val SPINNER_TIMEOUT_MS = 25_000L
|
||||||
|
|
||||||
/** 저장된 기기 삭제 다이얼로그 상태 (2026-08-04 · BLE bonding 대칭성 fix). */
|
/** 저장된 기기 삭제 다이얼로그 상태 (2026-08-04 · BLE bonding 대칭성 fix). */
|
||||||
private enum class UnpairPhase { Confirm, Reconnecting, OfflineWarning }
|
private enum class UnpairPhase { Confirm, Reconnecting, OfflineWarning }
|
||||||
|
|
||||||
@@ -144,6 +150,27 @@ fun DeviceScanView(appState: AppState) {
|
|||||||
if (connectionError != null) connectingDeviceAddress = null
|
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() } }
|
DisposableEffect(Unit) { onDispose { bleManager.stopScan() } }
|
||||||
|
|
||||||
// 페어링 안내 다이얼로그
|
// 페어링 안내 다이얼로그
|
||||||
|
|||||||
@@ -370,6 +370,7 @@
|
|||||||
<string name="device_scan_troubleshoot_4">기기를 다시 켜보세요</string>
|
<string name="device_scan_troubleshoot_4">기기를 다시 켜보세요</string>
|
||||||
<string name="device_scan_enter_measurement_dev">측정 모드 진입 (Dev)</string>
|
<string name="device_scan_enter_measurement_dev">측정 모드 진입 (Dev)</string>
|
||||||
<string name="device_scan_saved_devices">저장된 기기</string>
|
<string name="device_scan_saved_devices">저장된 기기</string>
|
||||||
|
<string name="device_scan_connect_stuck_err">연결이 끝나지 않았습니다. 기기를 다시 눌러 주세요.</string>
|
||||||
<string name="device_scan_new_devices">새 기기</string>
|
<string name="device_scan_new_devices">새 기기</string>
|
||||||
<string name="device_scan_grant_permissions">권한 허용</string>
|
<string name="device_scan_grant_permissions">권한 허용</string>
|
||||||
<string name="device_scan_stop_scan">검색 중지</string>
|
<string name="device_scan_stop_scan">검색 중지</string>
|
||||||
|
|||||||
@@ -370,6 +370,7 @@
|
|||||||
<string name="device_scan_troubleshoot_4">Try restarting the device</string>
|
<string name="device_scan_troubleshoot_4">Try restarting the device</string>
|
||||||
<string name="device_scan_enter_measurement_dev">Enter Measurement Mode (Dev)</string>
|
<string name="device_scan_enter_measurement_dev">Enter Measurement Mode (Dev)</string>
|
||||||
<string name="device_scan_saved_devices">Saved Devices</string>
|
<string name="device_scan_saved_devices">Saved Devices</string>
|
||||||
|
<string name="device_scan_connect_stuck_err">Connection did not finish. Tap the device again.</string>
|
||||||
<string name="device_scan_new_devices">New Devices</string>
|
<string name="device_scan_new_devices">New Devices</string>
|
||||||
<string name="device_scan_grant_permissions">Grant Permissions</string>
|
<string name="device_scan_grant_permissions">Grant Permissions</string>
|
||||||
<string name="device_scan_stop_scan">Stop Scan</string>
|
<string name="device_scan_stop_scan">Stop Scan</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user