fix(ble): 광고를 확인하고 연결한다 — 반쪽 연결을 원인에서 막는다

앞 커밋은 반쪽 연결을 **복구**했다. 이건 **생기지 않게** 한다.

## 전제가 틀렸다

`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 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 15:34:14 +09:00
parent f676bd7c8a
commit 44460d5149
4 changed files with 127 additions and 4 deletions
@@ -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<String, Long>()
/** 이 안에 광고를 봤으면 "지금 광고 중"으로 본다. */
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