From 44460d51499e687763aceef060aaac7c663473e7 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Thu, 10 Sep 2026 15:34:14 +0900 Subject: [PATCH] =?UTF-8?q?fix(ble):=20=EA=B4=91=EA=B3=A0=EB=A5=BC=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=ED=95=98=EA=B3=A0=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20=E2=80=94=20=EB=B0=98=EC=AA=BD=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=EC=9D=84=20=EC=9B=90=EC=9D=B8=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=A7=89=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋은 반쪽 연결을 **복구**했다. 이건 **생기지 않게** 한다. ## 전제가 틀렸다 `connectByAddress` 는 바로 connectGatt 했다. 주석이 "스캔 결과에 안 잡혀도 OS 본드가 있으면 바로 됨"이라고 적고 있었는데, **BLE 는 상대가 광고(connectable)하지 않으면 연결이 성립하지 않는다.** 광고 전/직후에 [저장된 기기] 를 누르면 링크만 붙고 서비스가 안 올라온다 — 현장 재연 ③의 "광고를 하기 전 또는 직후, 휴대폰이 인지하지 못한 상황" 이 정확히 그것이다. ## 방금 봤으면 기다리지 않는다 주소별로 광고를 마지막에 본 시각을 들고 있다가(`lastSeenAtMs`), 5초 안이면 바로 연결한다. 기기 목록 화면은 이미 스캔 중이라 **대부분 이 경로**다 — 평소 연결이 느려지지 않는다. 매번 스캔을 돌리면 안드로이드 스캔 횟수 제한(30초에 5회)에 걸린다. 못 봤으면 그 주소만 필터로 걸어 최대 6초 기다린다. 광고가 오면 그 ScanResult 의 device 로 연결하고, 안 오면 "기기를 찾을 수 없습니다. 전원이 켜져 있는지 확인한 뒤 다시 눌러 주세요." 로 끝낸다 — **연결을 시도하지 않으므로 반쪽 연결이 애초에 안 생긴다.** 기록은 **이름 검사보다 먼저** 한다. 광고에 이름이 안 실린 패킷도 "지금 광고 중"이라는 증거다 — 놓치면 멀쩡히 광고하는 기기를 6초 기다린다. ## 확인이 불가능할 때는 막지 않는다 스캐너가 없거나 스캔이 실패하면(권한·횟수 제한) 확인을 포기하고 그냥 시도한다. 여기서 멈추면 연결할 길이 아예 없어진다. 그 경우는 앞 커밋의 반쪽 연결 가드(20초)가 받는다. ## unbondSmart 타임아웃 5초 → 14초 그 함수는 `connectByAddress` 로 재연결한 뒤 msr? 를 보낸다. 광고 대기가 최대 6초라 5초면 스캔 단계에서 잘려 **항상 "기기 오프라인"** 으로 떨어진다. 테스트 143개 통과. Co-Authored-By: Claude Opus 5 --- .../com/medithings/vesiscan/ble/BleManager.kt | 126 +++++++++++++++++- .../ui/views/connection/DeviceScanView.kt | 3 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 127 insertions(+), 4 deletions(-) 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 edff81a..a52cffa 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -403,6 +403,10 @@ class BleManager private constructor(private val context: Context) { private val scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { val device = result.device + // **이름 검사보다 먼저** 기록한다. 광고에 이름이 안 실린 패킷도 "지금 광고 + // 중"이라는 증거다 — 그걸 놓치면 멀쩡히 광고하는 기기를 기다리게 된다. + lastSeenAtMs[device.address] = System.currentTimeMillis() + val name = try { device.name } catch (_: Exception) { null } ?: return if (name.isEmpty()) return @@ -464,6 +468,24 @@ class BleManager private constructor(private val context: Context) { /** 마지막으로 연결을 끊은 시각. 쿨다운 계산에만 쓴다. */ @Volatile private var lastDisconnectAtMs = 0L + /** + * 주소별로 **광고를 마지막으로 본 시각.** + * + * 저장된 기기를 누를 때 "지금 광고 중인가"를 판단하는 데 쓴다. 광고 전에 연결을 걸면 + * 링크만 붙고 서비스가 안 올라오는 **반쪽 연결**이 된다(2026-09-10 현장 재연 ③). + */ + private val lastSeenAtMs = java.util.concurrent.ConcurrentHashMap() + + /** 이 안에 광고를 봤으면 "지금 광고 중"으로 본다. */ + private val ADVERT_FRESH_MS = 5_000L + + /** 광고를 못 봤을 때 기다리는 시간. 넘기면 "기기를 찾을 수 없음". */ + private val ADVERT_WAIT_MS = 6_000L + + /** 광고 확인용 스캔 콜백. 하나만 돈다. */ + private var waitAdvertCallback: ScanCallback? = null + private var waitAdvertTimeout: Runnable? = null + /** * **명령을 실제로 보낼 수 있는가.** [isConnected] 와 다르다. * @@ -722,7 +744,11 @@ class BleManager private constructor(private val context: Context) { */ fun unbondSmart( address: String, - timeoutMs: Long = 5_000L, + /** + * 5초였는데 늘렸다. `connectByAddress` 가 광고를 최대 6초 기다린 뒤 연결하므로 + * (2026-09-10), 5초면 스캔 단계에서 잘려 항상 "기기 오프라인"으로 떨어진다. + */ + timeoutMs: Long = 14_000L, onSuccess: () -> Unit, onDeviceOffline: () -> Unit, ) { @@ -776,9 +802,100 @@ class BleManager private constructor(private val context: Context) { connectionError.value = "Invalid device address" return } - connect(BleDevice(device = device, rssi = 0)) - // discoveredDevices 에 없을 수 있으므로 name 을 별도로 저장 (기존 connect 가 onConnected 시점에 name 설정) - connectedDeviceName.value = name + + // ── 광고를 확인하고 연결한다 ──────────────────────────────────────── + // 종전에는 바로 connectGatt 했다. "스캔에 안 잡혀도 OS 본드가 있으면 된다"는 + // 의도였는데, **BLE 는 상대가 광고(connectable)하지 않으면 연결이 성립하지 않는다.** + // 광고 전/직후에 누르면 링크만 붙고 서비스가 안 올라오는 반쪽 연결이 되고, 그 + // 상태에서 [페어링 삭제] 를 누르면 본드가 비대칭이 되어 기기를 물리적으로 + // 초기화해야 한다(2026-09-10 현장 재연 ③~⑩). + // + // 방금 광고를 봤으면 기다리지 않는다 — 기기 목록 화면은 이미 스캔 중이라 대부분 + // 이 경로다. 추가 스캔을 매번 돌리면 안드로이드 스캔 횟수 제한(30초에 5회)에 + // 걸린다. + val seenAgo = System.currentTimeMillis() - (lastSeenAtMs[address] ?: 0L) + if (seenAgo <= ADVERT_FRESH_MS) { + logd { "connectByAddress: 광고 ${seenAgo}ms 전에 봄 — 바로 연결" } + connect(BleDevice(device = device, rssi = 0)) + connectedDeviceName.value = name + return + } + waitForAdvertThenConnect(address, name, device) + } + + /** + * 그 주소가 광고할 때까지 기다렸다가 연결한다. [ADVERT_WAIT_MS] 안에 못 보면 실패. + * + * 주소 필터를 걸어 돈다 — 전체 스캔보다 싸고, 다른 기기 광고로 깨어나지 않는다. + */ + private fun waitForAdvertThenConnect(address: String, name: String, device: BluetoothDevice) { + val scanner = bluetoothAdapter?.bluetoothLeScanner ?: run { + // 스캐너가 없으면 확인할 방법이 없다 — 종전처럼 바로 시도한다. + // 반쪽 연결 가드(armHalfConnectedGuard)가 뒤를 받는다. + logw { "waitForAdvert: scanner 없음 — 확인 없이 연결" } + connect(BleDevice(device = device, rssi = 0)) + connectedDeviceName.value = name + return + } + cancelAdvertWait() + isConnecting.value = true + connectionError.value = null + debugLogger.info("ADVERT_WAIT $name ($address) — 광고 확인 후 연결 (최대 ${ADVERT_WAIT_MS}ms)") + + val cb = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + if (result.device.address != address) return + lastSeenAtMs[address] = System.currentTimeMillis() + debugLogger.info("ADVERT_FOUND $name rssi=${result.rssi} — 연결") + cancelAdvertWait() + connect(BleDevice(device = result.device, rssi = result.rssi)) + connectedDeviceName.value = name + } + + override fun onScanFailed(errorCode: Int) { + // 스캔 자체가 안 되면(권한·횟수 제한) 확인을 포기하고 시도한다 — + // 여기서 멈추면 연결할 길이 아예 없어진다. + logw { "waitForAdvert: scan failed $errorCode — 확인 없이 연결" } + debugLogger.warn("ADVERT_SCAN_FAIL $errorCode — 확인 없이 연결") + cancelAdvertWait() + connect(BleDevice(device = device, rssi = 0)) + connectedDeviceName.value = name + } + } + waitAdvertCallback = cb + + val filters = listOf(ScanFilter.Builder().setDeviceAddress(address).build()) + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + try { + scanner.startScan(filters, settings, cb) + } catch (e: Exception) { + logw { "waitForAdvert: startScan 실패 ${e.message} — 확인 없이 연결" } + waitAdvertCallback = null + connect(BleDevice(device = device, rssi = 0)) + connectedDeviceName.value = name + return + } + + val to = Runnable { + waitAdvertTimeout = null + cancelAdvertWait() + isConnecting.value = false + debugLogger.warn("ADVERT_TIMEOUT $name ($address) — 광고가 없습니다") + connectionError.value = "DEVICE_NOT_ADVERTISING" + } + waitAdvertTimeout = to + handler.postDelayed(to, ADVERT_WAIT_MS) + } + + private fun cancelAdvertWait() { + waitAdvertCallback?.let { cb -> + try { bluetoothAdapter?.bluetoothLeScanner?.stopScan(cb) } catch (_: Exception) {} + } + waitAdvertCallback = null + waitAdvertTimeout?.let { handler.removeCallbacks(it) } + waitAdvertTimeout = null } /** 저장된 마지막 기기에 자동 연결 시도 */ @@ -794,6 +911,7 @@ class BleManager private constructor(private val context: Context) { fun disconnect() { lastDisconnectAtMs = System.currentTimeMillis() clearHalfConnectedGuard() + cancelAdvertWait() // 쿨다운 예약을 버린다. 안 버리면 끊은 뒤 600ms 만에 스스로 다시 연결된다. pendingConnect?.let { handler.removeCallbacks(it); pendingConnect = null } isUserDisconnect = true 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 3a0482d..bc11077 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 @@ -267,6 +267,8 @@ fun DeviceScanView(appState: AppState) { val isHalfConnected = curError == "HALF_CONNECTED" // 본드 비대칭을 막으려고 페어링 삭제를 거부한 경우. 기기 초기화가 필요하다. val isUnbondUnreachable = curError == "UNBOND_UNREACHABLE" + // 광고가 없어 연결을 시도하지 않은 경우. 반쪽 연결을 애초에 막는다. + val isNotAdvertising = curError == "DEVICE_NOT_ADVERTISING" Column( modifier = Modifier .fillMaxWidth() @@ -284,6 +286,7 @@ fun DeviceScanView(appState: AppState) { 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) + isNotAdvertising -> stringResource(R.string.ble_error_not_advertising) 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 e0188e7..5f2562b 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -373,6 +373,7 @@ 연결이 끝나지 않았습니다. 기기를 다시 눌러 주세요. 연결이 끝까지 되지 않았습니다 — 기기가 응답했지만 준비를 마치지 못했습니다. 다시 연결합니다. 페어링을 삭제할 수 없습니다: 기기에 명령이 닿지 않습니다. 기기 버튼을 15초 길게 눌러 초기화한 뒤, 저장된 기기를 삭제하고 다시 페어링하세요. + 기기를 찾을 수 없습니다. 전원이 켜져 있는지 확인한 뒤 다시 눌러 주세요. 새 기기 권한 허용 검색 중지 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ec414f9..6e9273e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -373,6 +373,7 @@ 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. + Device not found. Make sure it is powered on, then tap it again. New Devices Grant Permissions Stop Scan