feat(ble): 저장된 기기 삭제를 즉시 폰 쪽부터 지우고, 연결 직후 끊김 반복을 "기기 초기화 필요"로 알린다
사용자 앱 e7189afb 와 같은 변경을 임상 앱에 옮긴다. 2026-08-04 의 삭제 흐름은 "확인 → 14초
재연결 → 실패 시 강제 삭제" 였다. 지우려는 기기는 대개 이미 연결이 안 되는 기기라 그 14초는
정의상 실패했고, 사용자에게는 "삭제가 안 된다"로 읽혔다(10-06 사용적합성평가 · 10-07 임상 폰).
기기 쪽 본드는 연결된 채 msr? 를 보내거나 기기 15초 초기화로만 지워진다는 제약은 그대로
두고(기기 변경 없음), 앱 쪽 순서를 바꾼다.
· 삭제는 항상 즉시 폰 쪽을 지운다. 기다리는 단계가 기본 경로에 없다.
· 지금 연결돼 있으면 기기 쪽도 같이 지운다(msr?). 사용자가 고를 일이 없다.
· 방금(5초 안) 광고를 본 기기만 7초 접속해 양쪽을 지우려 하고(BleManager.lastSeenAgoMs
신설), 그 사이 [기다리지 않고 삭제] 가 있다(abortUnbondSmart). 닿지 않으면 폰에서만
지우고 파란 알림으로 "기기 15초 초기화 필요" 를 말한다.
· 폰에서만 지운 주소는 ResetNeededStore 에 남기고, 새 기기 목록에 다시 보이면 신호 세기
자리에 "초기화하지 않았다면 연결이 거절됩니다" 를 붙인다. 그 기기와 GATT 연결이 성립하면
지운다(이 앱에는 본드 수신기가 없어 연결 성공 분기에서 거둔다).
· 본드 불일치 감지(FastFailDetector): connectGatt 뒤 1.5초 안에 실패가 같은 주소로 2번
연속이면 BOND_MISMATCH_SUSPECTED. 자동 재시도를 멈추고 기기 15초 초기화를 안내한다.
폰에 본드가 남아 있을 때만 [이 폰의 본딩 정보 초기화] 를 함께 준다(isAddressBonded 신설).
· 강제 삭제 뒤 옛 오류 배너가 남던 것도 같이 지운다(사용자 앱 9c115871).
문구: unpair_* 를 상황별 세 가지로 갈고 안 쓰는 6개는 지웠다. FastFailDetectorTest 6건.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -123,6 +123,15 @@ class BleManager private constructor(private val context: Context) {
|
||||
var lastConnectedAddress: String? = null
|
||||
private set
|
||||
private var reconnectAttempt = 0
|
||||
|
||||
/** 연결 성립 직후 끊김 반복 감지 — 본드 불일치 의심([FastFailDetector], 2026-10-08). */
|
||||
private val fastFail = FastFailDetector()
|
||||
|
||||
/** 마지막 connectGatt 호출 시각. 실패까지 걸린 시간을 재는 기준이다. */
|
||||
private var connectStartedAtMs = 0L
|
||||
|
||||
/** 진행 중인 스마트 삭제의 시간초과 런너블 — [abortUnbondSmart] 가 당겨 부른다. */
|
||||
private var unbondSmartTimeout: Runnable? = null
|
||||
private val reconnectIntervalMs: Long = 3000
|
||||
/** max retry — 10회 시도 후 포기 (≈ 8s scan × 10 + 간격, 약 100~120초). */
|
||||
// 2026-08-04: 10 → 5 축소. 지속 실패 시 사용자에게 명확한 안내가 낫고 무한 시도로
|
||||
@@ -808,6 +817,7 @@ class BleManager private constructor(private val context: Context) {
|
||||
isConnecting.value = true
|
||||
|
||||
try {
|
||||
connectStartedAtMs = System.currentTimeMillis()
|
||||
bluetoothGatt = bleDevice.device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
logd { "connectGatt called, gatt=${bluetoothGatt != null}" }
|
||||
} catch (e: Exception) {
|
||||
@@ -895,6 +905,11 @@ class BleManager private constructor(private val context: Context) {
|
||||
}
|
||||
com.medithings.vesiscan.services.KnownDeviceStore.remove(context, address)
|
||||
debugLogger.info("DEVICE_UNREGISTERED $address")
|
||||
// 기기 쪽 페어링이 남아 있을 수 있는 주소로 기억한다 — 새 기기 목록에 다시 보이면 안내를 붙인다.
|
||||
runCatching { com.medithings.vesiscan.services.ResetNeededStore.mark(context, address) }
|
||||
// 지웠으면 지난 오류 배너(BOND_CONFLICT 등)는 더 이상 사실이 아니다 — 같이 지운다(2026-10-06).
|
||||
connectionError.value = null
|
||||
bondConflictAddress.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -932,6 +947,7 @@ class BleManager private constructor(private val context: Context) {
|
||||
if (device == null) { onDeviceOffline(); return }
|
||||
|
||||
val timeoutRunnable = Runnable {
|
||||
unbondSmartTimeout = null
|
||||
debugLogger.warn("UNBOND_SMART timeout · reconnect failed for $address")
|
||||
try { bluetoothGatt?.disconnect(); bluetoothGatt?.close() } catch (_: Exception) {}
|
||||
bluetoothGatt = null
|
||||
@@ -944,6 +960,7 @@ class BleManager private constructor(private val context: Context) {
|
||||
originalCallback?.invoke(connected)
|
||||
if (connected && bluetoothGatt?.device?.address == address) {
|
||||
handler.removeCallbacks(timeoutRunnable)
|
||||
unbondSmartTimeout = null
|
||||
onConnectionStateChanged = originalCallback
|
||||
debugLogger.info("UNBOND_SMART reconnected · sending msr? for full unbond")
|
||||
disconnectAndUnbond()
|
||||
@@ -951,10 +968,33 @@ class BleManager private constructor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
unbondSmartTimeout = timeoutRunnable
|
||||
handler.postDelayed(timeoutRunnable, timeoutMs)
|
||||
connectByAddress(address, address)
|
||||
}
|
||||
|
||||
/**
|
||||
* 스마트 삭제의 접속 대기를 지금 끝낸다 — [unbondSmart] 의 `onDeviceOffline` 이 바로 불린다.
|
||||
* 화면의 [기다리지 않고 삭제] 가 부른다(2026-10-08). 진행 중인 삭제가 없으면 아무 일도 없다.
|
||||
*/
|
||||
fun abortUnbondSmart() {
|
||||
val r = unbondSmartTimeout ?: return
|
||||
handler.removeCallbacks(r)
|
||||
debugLogger.info("UNBOND_SMART aborted by user — 폰에서만 지운다")
|
||||
r.run()
|
||||
}
|
||||
|
||||
/** 이 주소의 광고를 마지막으로 본 뒤 지난 시간(ms). 본 적 없으면 null. 삭제 화면이 "근처인가"를 가른다. */
|
||||
fun lastSeenAgoMs(address: String): Long? =
|
||||
lastSeenAtMs[address]?.let { System.currentTimeMillis() - it }
|
||||
|
||||
/** 폰(OS)이 이 주소와 본드를 들고 있는가. */
|
||||
fun isAddressBonded(address: String): Boolean {
|
||||
val adapter = bluetoothAdapter ?: return false
|
||||
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull() ?: return false
|
||||
return runCatching { device.bondState == android.bluetooth.BluetoothDevice.BOND_BONDED }.getOrDefault(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 기기 주소로 직접 연결. 스캔 결과에 안 잡혀도 OS bond 가 있으면 바로 됨.
|
||||
* KnownDeviceStore 에서 골라 호출.
|
||||
@@ -1218,6 +1258,7 @@ class BleManager private constructor(private val context: Context) {
|
||||
|
||||
isReconnecting.value = true
|
||||
val device = bluetoothAdapter.getRemoteDevice(lastDeviceAddress)
|
||||
connectStartedAtMs = System.currentTimeMillis()
|
||||
bluetoothGatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
|
||||
handler.postDelayed({
|
||||
@@ -2038,6 +2079,24 @@ class BleManager private constructor(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
// 연결이 성립된 직후 끊기는 일이 반복되면 본드 불일치를 의심한다(2026-10-07 실측:
|
||||
// 링크 성립 → 0.05초 뒤 기기가 인증 실패 0x05 로 끊음, 8회 동일. 앱에는 status 133
|
||||
// 으로만 온다). 기기가 다른 폰의 본드를 들고 있거나 이 폰의 옛 본드를 모르는 것이라
|
||||
// 재시도로 풀리지 않는다 — 기기 15초 초기화가 답이다. 종전에는 일반 "끊김"으로만
|
||||
// 보여 사용자가 폰 쪽만 몇 번씩 지웠다. (사용자 앱 e7189afb 와 같은 변경)
|
||||
val elapsed = if (connectStartedAtMs > 0L) System.currentTimeMillis() - connectStartedAtMs else 0L
|
||||
if (!wasConnected &&
|
||||
fastFail.onFailure(gatt.device.address, elapsed, System.currentTimeMillis())
|
||||
) {
|
||||
cancelAutoReconnect()
|
||||
bondConflictAddress.value = gatt.device.address
|
||||
connectionError.value = "BOND_MISMATCH_SUSPECTED:${gatt.device.address}"
|
||||
debugLogger.warn(
|
||||
"BOND_MISMATCH_SUSPECTED addr=${gatt.device.address} — 연결 직후 끊김 반복(${elapsed}ms), 재시도 중단")
|
||||
onReconnectionFailed?.invoke()
|
||||
return@post
|
||||
}
|
||||
|
||||
if (wasConnected && lastConnectedAddress != null && !isUserDisconnect) {
|
||||
onUnexpectedDisconnect?.invoke()
|
||||
scheduleAutoReconnect()
|
||||
@@ -2110,6 +2169,9 @@ class BleManager private constructor(private val context: Context) {
|
||||
// 2026-07-10: clinical 흐름 조건 제거 — 어떤 진입 경로든 연결 성공 시
|
||||
// KnownDeviceStore 에 영속화. 재사용 편의 (재연결 대상 자동 감지 등).
|
||||
try {
|
||||
// 기기가 연결을 받아 줬다 — "폰에서만 지운 기기" 표시를 거두고 빠른 실패 연속을 끊는다.
|
||||
runCatching { com.medithings.vesiscan.services.ResetNeededStore.clear(context, gatt.device.address) }
|
||||
fastFail.reset()
|
||||
com.medithings.vesiscan.services.KnownDeviceStore.add(
|
||||
context, gatt.device.address, connectedDeviceName.value
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user