feat(ble): unbondSmart() · 삭제 시 자동 재연결 + 오프라인 경고 팝업 (cloud-mvp 1982399 이식)

This commit is contained in:
2026-08-04 17:03:38 +09:00
parent 6007c29c9d
commit a992a88b86
4 changed files with 121 additions and 16 deletions
@@ -410,6 +410,60 @@ class BleManager private constructor(private val context: Context) {
debugLogger.info("DEVICE_UNREGISTERED $address")
}
/**
* 스마트 삭제 (2026-08-04): 미연결 상태에서 삭제 시 기기 재연결 후 msr? 전송.
*
* 배경: 기기가 오프라인일 때 unbondAndRemoveAddress() 는 폰 측 LTK 만 삭제 →
* 기기는 여전히 이전 LTK 를 기대 · 재페어링 시 PIN 불일치 · 사용자가 기기
* 15초 롱프레스로 강제 초기화해야만 재페어링 가능.
*
* 이 함수는:
* - 이미 연결됨 → disconnectAndUnbond() (msr? + 양쪽 removeBond) → onSuccess
* - 미연결 → timeoutMs 재연결 시도
* 성공 → disconnectAndUnbond() → onSuccess
* 실패 → onDeviceOffline() (UI 가 강제 삭제 결정)
*/
fun unbondSmart(
address: String,
timeoutMs: Long = 5_000L,
onSuccess: () -> Unit,
onDeviceOffline: () -> Unit,
) {
if (bluetoothGatt?.device?.address == address && isServiceReady.value) {
disconnectAndUnbond()
onSuccess()
return
}
debugLogger.info("UNBOND_SMART attempting reconnect for $address (timeout=${timeoutMs}ms)")
val adapter = bluetoothAdapter ?: run { onDeviceOffline(); return }
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull()
if (device == null) { onDeviceOffline(); return }
val timeoutRunnable = Runnable {
debugLogger.warn("UNBOND_SMART timeout · reconnect failed for $address")
try { bluetoothGatt?.disconnect(); bluetoothGatt?.close() } catch (_: Exception) {}
bluetoothGatt = null
isConnecting.value = false
onDeviceOffline()
}
val originalCallback = onConnectionStateChanged
onConnectionStateChanged = { connected ->
originalCallback?.invoke(connected)
if (connected && bluetoothGatt?.device?.address == address) {
handler.removeCallbacks(timeoutRunnable)
onConnectionStateChanged = originalCallback
debugLogger.info("UNBOND_SMART reconnected · sending msr? for full unbond")
disconnectAndUnbond()
onSuccess()
}
}
handler.postDelayed(timeoutRunnable, timeoutMs)
connectByAddress(address, address)
}
/**
* 저장된 기기 주소로 직접 연결. 스캔 결과에 안 잡혀도 OS bond 가 있으면 바로 됨.
* KnownDeviceStore 에서 골라 호출.
@@ -47,6 +47,9 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** 저장된 기기 삭제 다이얼로그 상태 (2026-08-04 · BLE bonding 대칭성 fix). */
private enum class UnpairPhase { Confirm, Reconnecting, OfflineWarning }
@Composable
fun DeviceScanView(appState: AppState) {
val context = LocalContext.current
@@ -375,23 +378,57 @@ fun DeviceScanView(appState: AppState) {
}
}
// Delete Known Device 확인 다이얼로그
// Delete Known Device — 2단계 다이얼로그 (2026-08-04 · BLE bonding 대칭성 fix).
var unpairPhase by remember { mutableStateOf<UnpairPhase>(UnpairPhase.Confirm) }
pendingDelete?.let { target ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text(stringResource(R.string.unpair_title)) },
text = { Text(stringResource(R.string.unpair_message, target.name)) },
confirmButton = {
TextButton(onClick = {
bleManager.unbondAndRemoveAddress(target.address)
knownDevices = KnownDeviceStore.getAll(context)
pendingDelete = null
}) { Text(stringResource(R.string.unpair_confirm), color = Color.Red) }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.cancel)) }
}
)
when (unpairPhase) {
UnpairPhase.Confirm -> AlertDialog(
onDismissRequest = { pendingDelete = null; unpairPhase = UnpairPhase.Confirm },
title = { Text(stringResource(R.string.unpair_title)) },
text = { Text(stringResource(R.string.unpair_message, target.name)) },
confirmButton = {
TextButton(onClick = {
unpairPhase = UnpairPhase.Reconnecting
bleManager.unbondSmart(
address = target.address,
onSuccess = {
knownDevices = KnownDeviceStore.getAll(context)
pendingDelete = null
unpairPhase = UnpairPhase.Confirm
},
onDeviceOffline = { unpairPhase = UnpairPhase.OfflineWarning },
)
}) { Text(stringResource(R.string.unpair_confirm), color = Color.Red) }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.cancel)) }
}
)
UnpairPhase.Reconnecting -> AlertDialog(
onDismissRequest = { /* 대기 필수 */ },
title = { Text(stringResource(R.string.unpair_reconnecting_title)) },
text = { Text(stringResource(R.string.unpair_reconnecting_desc)) },
confirmButton = { },
)
UnpairPhase.OfflineWarning -> AlertDialog(
onDismissRequest = { pendingDelete = null; unpairPhase = UnpairPhase.Confirm },
title = { Text(stringResource(R.string.unpair_offline_title)) },
text = { Text(stringResource(R.string.unpair_offline_desc)) },
confirmButton = {
TextButton(onClick = {
bleManager.unbondAndRemoveAddress(target.address)
knownDevices = KnownDeviceStore.getAll(context)
pendingDelete = null
unpairPhase = UnpairPhase.Confirm
}) { Text(stringResource(R.string.unpair_force_delete), color = Color.Red) }
},
dismissButton = {
TextButton(onClick = {
unpairPhase = UnpairPhase.Confirm
}) { Text(stringResource(R.string.unpair_retry_connect)) }
}
)
}
}
// Scan button
+7
View File
@@ -147,6 +147,13 @@
<string name="unpair_title">페어링 해제</string>
<string name="unpair_message">%s 의 페어링을 해제합니다. 다시 사용하려면 PIN 재입력이 필요합니다.</string>
<string name="unpair_confirm">해제</string>
<!-- 2026-08-04 · BLE bonding 대칭성 fix (unbondSmart flow) -->
<string name="unpair_reconnecting_title">기기 재연결 중…</string>
<string name="unpair_reconnecting_desc">기기에 접속해 양쪽 페어링 정보를 함께 삭제합니다. 이 과정 없이 삭제하면 다음 페어링 시 기기 초기화가 필요할 수 있습니다.</string>
<string name="unpair_offline_title">기기가 응답하지 않습니다</string>
<string name="unpair_offline_desc">기기가 오프라인 상태입니다. 여기서 삭제하면 이 폰의 페어링 정보만 지워지며, 다음 페어링 시 기기 초기화가 필요합니다 (전원 15초 롱프레스).\n\n선택하세요:</string>
<string name="unpair_force_delete">강제 삭제</string>
<string name="unpair_retry_connect">기기 켜고 다시 시도</string>
<!-- Reminder Notifications -->
<string name="notification_channel_name">배뇨 알림</string>
+7
View File
@@ -147,6 +147,13 @@
<string name="unpair_title">Unpair Device</string>
<string name="unpair_message">Unpair %s. PIN re-entry will be required to use it again.</string>
<string name="unpair_confirm">Unpair</string>
<!-- 2026-08-04 · BLE bonding 대칭성 fix (unbondSmart flow) -->
<string name="unpair_reconnecting_title">Reconnecting to device…</string>
<string name="unpair_reconnecting_desc">Contacting the device to clear pairing on both sides. This ensures re-pairing works without device reset.</string>
<string name="unpair_offline_title">Device is offline</string>
<string name="unpair_offline_desc">The device did not respond. Deleting here will remove pairing on this phone only. To pair again, the device will need to be reset (long-press power button for 15 seconds).\n\nChoose an action:</string>
<string name="unpair_force_delete">Force delete</string>
<string name="unpair_retry_connect">Turn on device &amp; retry</string>
<!-- Reminder Notifications -->
<string name="notification_channel_name">Voiding reminder</string>