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