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 5094b9a..c07675a 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -380,11 +380,66 @@ class BleManager private constructor(private val context: Context) { val isBluetoothReady: Boolean get() = bluetoothAdapter?.isEnabled == true // Scanning + // ── 스캔 빈도 제한 ──────────────────────────────────────────────────── + // + // 안드로이드는 앱이 **30초에 5회** 이상 스캔을 시작하면 그 뒤 스캔을 **조용히** + // 막는다. 결과가 빈 채로 돌아오므로 앱에는 "광고가 없습니다" 로 보이고, 실제로는 + // 기기가 멀쩡히 광고하는 중이다 (2026-09-15 실기: LED 는 광고 중이고 다른 폰에서는 + // 잡히는데 이 폰만 못 봤다. 시스템 로그에 근거가 그대로 남는다 — + // `E/BtGatt.GattService: App '...' is scanning too frequently`). + // + // 연결/해제를 빠르게 반복하면 `waitForAdvertThenConnect` 가 매번 스캔을 걸어 네다섯 + // 번에 걸린다. OS 가 막기 전에 **앱에서 먼저 막고 남은 시간을 알려 준다** — 막힌 채로 + // "광고가 없습니다" 를 보여 주면 사용자가 기기를 의심하게 되고, 실제로 그랬다. + + private val SCAN_QUOTA_WINDOW_MS = 30_000L + + /** OS 한도는 5 다. 하나를 남겨 둬야 자동 재연결 같은 내부 경로가 굶지 않는다. */ + private val SCAN_QUOTA_MAX = 4 + + /** 최근 스캔 시작 시각 — [SCAN_QUOTA_WINDOW_MS] 안의 것만 들고 있는다. */ + private val scanStarts = ArrayDeque() + + /** + * 지금 스캔해도 되는가. 0 이면 가능, 아니면 **남은 대기 ms**. + * 창을 벗어난 기록은 여기서 버린다. + */ + private fun scanQuotaWaitMs(): Long { + val now = System.currentTimeMillis() + while (scanStarts.isNotEmpty() && now - scanStarts.first() > SCAN_QUOTA_WINDOW_MS) { + scanStarts.removeFirst() + } + if (scanStarts.size < SCAN_QUOTA_MAX) return 0L + return SCAN_QUOTA_WINDOW_MS - (now - scanStarts.first()) + } + + /** 스캔을 실제로 시작했을 때 부른다. */ + private fun noteScanStart() { + scanStarts.addLast(System.currentTimeMillis()) + } + + /** + * 제한에 걸렸으면 사용자에게 알리고 true 를 돌려준다(= 스캔하지 말 것). + * `connectionError` 는 `SCAN_THROTTLED:<남은 초>` 형태다 — 화면이 초를 읽어 쓴다. + */ + private fun blockedByScanQuota(where: String): Boolean { + val wait = scanQuotaWaitMs() + if (wait <= 0L) return false + val sec = (wait / 1000L) + 1L + logw { "scan blocked at $where — quota, ${wait}ms left" } + debugLogger.warn("SCAN_QUOTA at=$where — 스캔 빈도 제한, ${sec}초 뒤 가능") + isScanning.value = false + isConnecting.value = false + connectionError.value = "SCAN_THROTTLED:$sec" + return true + } + fun startScan() { if (bluetoothAdapter?.isEnabled != true) { bluetoothEnabled.value = false return } + if (blockedByScanQuota("startScan")) return bluetoothEnabled.value = true discoveredDevices.clear() isScanning.value = true @@ -402,6 +457,7 @@ class BleManager private constructor(private val context: Context) { try { bluetoothLeScanner?.startScan(null, settings, scanCallback) + noteScanStart() logd { "BLE scan started" } } catch (e: Exception) { loge { "Failed to start scan: ${e.message}" } @@ -551,26 +607,42 @@ class BleManager private constructor(private val context: Context) { private var serviceDiscoveryStarted = false /** - * 서비스 탐색을 **이 연결에서 한 번만** 시작한다. + * 프로브의 MTU 협상이 지나갈 때까지 서비스 탐색을 늦추는 시간. * - * ## 왜 필요한가 (2026-09-15 실측으로 원인 확정) - * 프로브가 연결 직후 **자기 쪽에서 MTU 협상을 한 번 더 걸어온다** — 폰의 GATT - * 서버 로그에 `gatts_process_mtu_req: MTU 247 request from remote` 로 찍히고, - * 첫 협상 뒤 **일관되게 약 343ms** 시점이다. 그러면 안드로이드가 `onMtuChanged` - * 를 두 번 전달하고, 탐색을 그 콜백에서 시작하던 이 코드가 `discoverServices()` - * 를 두 번 불렀다. 앞의 탐색이 진행 중인데 다시 부르면 탐색이 끝나지 않고, - * 그 결과가 반쪽 연결이다 — 링크는 붙었는데 `txCharacteristic == null`. + * 프로브는 연결 뒤 **~300ms 시점에 자기 쪽에서 MTU 협상을 건다**(폰 GATT 서버 + * 로그의 `gatts_process_mtu_req: MTU 247 request from remote`). 그 ATT 트랜잭션이 + * **진행 중인 서비스 탐색을 깨뜨린다** — `onServicesDiscovered` 가 영원히 오지 않고 + * 반쪽 연결이 된다. 500ms 를 기다리면 협상이 끝난 뒤에 탐색이 시작되므로 깨질 + * 탐색이 없다. + */ + private val DISCOVER_DELAY_MS = 500L + + /** 지연 중인 탐색 — 연결이 끊기면 취소한다. */ + private var discoverPending: Runnable? = null + + /** + * 서비스 탐색을 **이 연결에서 한 번만**, 그리고 [DISCOVER_DELAY_MS] **뒤에** 시작한다. * - * 실측 상관관계가 깔끔하게 갈린다: - * · `MTU=` 1회 → `WATCHDOG_STARTED` (정상) - * · `MTU=` 2회 → 20초 뒤 `HALF_CONNECTED` - * 464건 전수에서도 같다 — 1회 97.0%(420/433) vs 2회 22.6%(7/31) 성공 - * (docs/FIRMWARE_BLE_FINDINGS.md). 2026-09-15 Xiaomi 23021RAA2Y 에서 한 세션 안에 - * 성공 6 / 반쪽 3 으로 재현됐고, 반쪽 3건 전부 `MTU=` 2회였다. + * ## 실측 (2026-09-15 · Xiaomi 23021RAA2Y · VBT2607R300) + * 성공/실패를 가르는 것은 두 번째 `onMtuChanged` 가 **언제** 오는지 하나였다. + * 오늘 9건에 예외가 없었다: * - * 프로브가 규격을 어기는 것은 아니다(MTU 재협상은 BLE 에서 허용된다). 콜백이 몇 - * 번 오든 탐색을 한 번만 시작하는 것은 **중앙 쪽 책임**이라, 펌웨어 배포를 기다리지 - * 않고 여기서 막는다. + * 두 번째 콜백 +1~2ms → WATCHDOG_STARTED 3/3 + * 두 번째 콜백 +297~342ms → HALF_CONNECTED 6/6 + * + * +1ms 는 탐색이 아직 시작도 안 했을 때라 살아남고, +300ms 는 탐색 한가운데라 + * 깨진다. 그래서 **중복 호출을 막는 것으로는 고쳐지지 않았다** — 실제로 막아 봤고 + * (`DISCOVER_SKIPPED`) 발동한 5회 중 3회가 여전히 반쪽이었다. 깨뜨리는 것은 우리 + * 호출이 아니라 프로브의 ATT 요청이고, 그건 앱이 막을 수 없다. + * + * 막을 수 없으니 **피한다** — 협상이 오는 창을 지나서 탐색을 시작한다. + * + * 대가는 연결 완료가 0.5초 늦는 것뿐이고, 실패하면 20초 `armHalfConnectedGuard` 가 + * 그대로 잡는다. 464건 전수의 "MTU 2회 → 22.6% 성공" 도 같은 현상을 다른 각도에서 + * 본 것이다(docs/FIRMWARE_BLE_FINDINGS.md). + * + * ⚠ 근본 원인은 프로브가 연결 직후 MTU 협상을 거는 것이다. 펌웨어에서 그것을 + * 없애거나 연결 직후 즉시 끝내면 이 지연은 필요 없어진다. */ private fun discoverServicesOnce(gatt: BluetoothGatt, where: String) { if (serviceDiscoveryStarted) { @@ -579,7 +651,24 @@ class BleManager private constructor(private val context: Context) { return } serviceDiscoveryStarted = true - gatt.discoverServices() + val r = Runnable { + discoverPending = null + // 지연 중에 끊겼거나 다른 연결로 바뀌었으면 그만둔다. + if (gatt !== bluetoothGatt) { + handler.post { debugLogger.info("DISCOVER_CANCELLED 연결이 바뀌었습니다") } + return@Runnable + } + handler.post { debugLogger.info("DISCOVER_START at=$where (+${DISCOVER_DELAY_MS}ms)") } + gatt.discoverServices() + } + discoverPending = r + handler.postDelayed(r, DISCOVER_DELAY_MS) + } + + /** 지연 중인 탐색을 취소한다 — 연결 정리 경로에서 부른다. */ + private fun cancelPendingDiscover() { + discoverPending?.let { handler.removeCallbacks(it) } + discoverPending = null } private fun armHalfConnectedGuard(gatt: BluetoothGatt) { @@ -600,6 +689,7 @@ class BleManager private constructor(private val context: Context) { rxCharacteristic = null } isConnected.value = false + cancelPendingDiscover() isServiceReady.value = false isConnecting.value = false connectionError.value = "HALF_CONNECTED" @@ -708,6 +798,7 @@ class BleManager private constructor(private val context: Context) { serialNumber.value = "" batteryLevel.value = 0 connectedDeviceName.value = "" + cancelPendingDiscover() isServiceReady.value = false val idx = discoveredDevices.indexOfFirst { it.address == bleDevice.address } @@ -766,6 +857,7 @@ class BleManager private constructor(private val context: Context) { txCharacteristic = null rxCharacteristic = null isConnected.value = false + cancelPendingDiscover() isServiceReady.value = false isConnecting.value = false connectionError.value = "Connection timed out. Try again." @@ -914,6 +1006,10 @@ class BleManager private constructor(private val context: Context) { return } cancelAdvertWait() + // 광고 확인은 스캔이다 — 제한에 걸리면 여기서 멈춘다. 확인 없이 연결하면 + // 광고하지 않는 기기에 붙으려다 오류가 나므로(ADVERT_WAIT 를 둔 이유) 그냥 + // 기다리게 하는 것이 맞다. + if (blockedByScanQuota("waitForAdvert")) return isConnecting.value = true connectionError.value = null debugLogger.info("ADVERT_WAIT $name ($address) — 광고 확인 후 연결 (최대 ${ADVERT_WAIT_MS}ms)") @@ -946,6 +1042,7 @@ class BleManager private constructor(private val context: Context) { .build() try { scanner.startScan(filters, settings, cb) + noteScanStart() } catch (e: Exception) { logw { "waitForAdvert: startScan 실패 ${e.message} — 확인 없이 연결" } waitAdvertCallback = null @@ -1061,6 +1158,7 @@ class BleManager private constructor(private val context: Context) { txCharacteristic = null rxCharacteristic = null isConnected.value = false + cancelPendingDiscover() isServiceReady.value = false connectedDeviceName.value = "" batteryLevel.value = 0 @@ -1115,6 +1213,7 @@ class BleManager private constructor(private val context: Context) { serialNumber.value = "" batteryLevel.value = 0 connectedDeviceName.value = "" + cancelPendingDiscover() isServiceReady.value = false isReconnecting.value = true @@ -1695,6 +1794,7 @@ class BleManager private constructor(private val context: Context) { reason = "watchdog zombie", ) isConnected.value = false + cancelPendingDiscover() isServiceReady.value = false connectedDeviceName.value = "" onUnexpectedDisconnect?.invoke() @@ -1719,6 +1819,7 @@ class BleManager private constructor(private val context: Context) { commandQueue.clear("disconnect") stopWatchdog() isConnected.value = false + cancelPendingDiscover() isServiceReady.value = false bluetoothGatt?.let { gatt -> @@ -1790,6 +1891,13 @@ class BleManager private constructor(private val context: Context) { scheduleAutoReconnect() return } + // 제한에 걸렸으면 지금 긁지 않는다 — OS 가 막으면 빈 결과가 와서 "못 찾음" 으로 + // 오해하게 된다. 재시도 경로이므로 다음 차례로 넘기면 된다. + if (scanQuotaWaitMs() > 0L) { + debugLogger.warn("SCAN_QUOTA at=reconnect — 스캔을 건너뛰고 다음 시도로") + scheduleAutoReconnect() + return + } // Stop any previous reconnect scan reconnectScanCallback?.let { @@ -1835,6 +1943,7 @@ class BleManager private constructor(private val context: Context) { txCharacteristic = null rxCharacteristic = null isConnected.value = false + cancelPendingDiscover() isServiceReady.value = false // 사용자가 끊은 게 아니다 — 계속 되살려야 한다. scheduleAutoReconnect() @@ -1849,6 +1958,7 @@ class BleManager private constructor(private val context: Context) { try { scanner.startScan(callback) + noteScanStart() } catch (e: Exception) { debugLogger.error("RECONNECT_SCAN_FAIL ${e.message}") scheduleAutoReconnect() @@ -1912,6 +2022,7 @@ class BleManager private constructor(private val context: Context) { txCharacteristic = null rxCharacteristic = null isConnected.value = false + cancelPendingDiscover() isServiceReady.value = false val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address } @@ -1962,6 +2073,7 @@ class BleManager private constructor(private val context: Context) { BluetoothProfile.STATE_CONNECTED -> { logd { "Connected to ${gatt.device.address}" } // 새 연결이므로 서비스 탐색을 아직 시작하지 않았다 ([discoverServicesOnce]). + cancelPendingDiscover() serviceDiscoveryStarted = false // 반쪽 연결 감시. 링크가 붙은 시점부터 재는 상한이다 — // connect() 의 타임아웃은 사용자가 시작한 경로에만 걸리므로, 재연결· @@ -2036,6 +2148,7 @@ class BleManager private constructor(private val context: Context) { txCharacteristic = null rxCharacteristic = null isConnected.value = false + cancelPendingDiscover() isServiceReady.value = false connectedDeviceName.value = "" firmwareVersion.value = "" 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 bc11077..5f4b5e6 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 @@ -269,6 +269,9 @@ fun DeviceScanView(appState: AppState) { val isUnbondUnreachable = curError == "UNBOND_UNREACHABLE" // 광고가 없어 연결을 시도하지 않은 경우. 반쪽 연결을 애초에 막는다. val isNotAdvertising = curError == "DEVICE_NOT_ADVERTISING" + // 안드로이드 스캔 빈도 제한. 기기 탓으로 오해하지 않게 남은 초를 적어 준다. + val throttledSec = curError.removePrefix("SCAN_THROTTLED:") + .takeIf { curError.startsWith("SCAN_THROTTLED:") } Column( modifier = Modifier .fillMaxWidth() @@ -287,6 +290,8 @@ fun DeviceScanView(appState: AppState) { isHalfConnected -> stringResource(R.string.ble_error_half_connected) isUnbondUnreachable -> stringResource(R.string.ble_error_unbond_unreachable) isNotAdvertising -> stringResource(R.string.ble_error_not_advertising) + throttledSec != null -> + stringResource(R.string.ble_error_scan_throttled, throttledSec) 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 bc8abc2..5c4af38 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -375,6 +375,9 @@ 프로브 전원을 껐다 켠 뒤 다시 연결해 주세요. 다시 시도해도 같으면 다른 프로브를 쓰세요. 페어링을 삭제할 수 없습니다: 기기에 명령이 닿지 않습니다. 기기 버튼을 15초 길게 눌러 초기화한 뒤, 저장된 기기를 삭제하고 다시 페어링하세요. + 기기 검색이 잠시 제한되었습니다 — %1$s초 뒤 다시 시도해 주세요. + +연결/해제를 빠르게 반복하면 안드로이드가 검색을 일시적으로 막습니다. 기기 문제가 아닙니다. 기기 신호가 잡히지 않습니다. 방금 연결을 끊었다면 기기가 다시 신호를 보낼 때까지 10~20초 걸릴 수 있습니다 — 잠시 뒤 다시 눌러 주세요. 계속 안 되면 전원을 확인하세요. 새 기기 권한 허용 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2d8031b..985cc7f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -375,6 +375,9 @@ Power the probe off and on, then connect again. If it repeats, use a different probe. 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 search is briefly limited — try again in %1$s s. + +Android throttles scanning when you connect and disconnect rapidly. This is not a problem with the device. No signal from the device. If you just disconnected, it can take 10-20 seconds to start advertising again — wait a moment and tap it again. If it keeps failing, check the power. New Devices Grant Permissions