feat(ble): GATT status=5 (FW allowlist bond conflict) UX (cloud-mvp 10c45dc 이식)

FW 팀이 1:1 bond allowlist 정책 추가 예정. 다른 central 과 이미 bond 중인
기기에 붙으면 GATT_INSUFFICIENT_AUTHENTICATION (status=5) 로 거부.

BleManager:
  - bondConflictAddress: MutableStateOf<String?> 신규
  - status=5 감지 시 bondConflictAddress=addr + connectionError="BOND_CONFLICT:$addr"
  - CONNECTED 성공 시 clear

DeviceScanView (ui/views/connection/):
  - 에러 배너 확장 (Column 2단):
    · 안내 문구 (기기 15초 롱프레스로 본딩 삭제)
    · "이 폰의 본딩 정보 초기화" OutlinedButton (unbondAndRemoveAddress)
  - Toast 완료 안내

strings.xml (KR/EN): ble_error_bond_conflict / ble_action_reset_bond / ble_toast_bond_removed
This commit is contained in:
2026-08-04 10:54:41 +09:00
parent de6e1d1931
commit fda9a4d97f
4 changed files with 68 additions and 11 deletions
@@ -201,6 +201,10 @@ class BleManager private constructor(private val context: Context) {
* ClinicalHome 등에서 "BLE Connecting…" 표시용. */
val isConnecting = mutableStateOf(false)
val connectionError = mutableStateOf<String?>(null)
/** 마지막으로 GATT status=5 (bond mismatch) 로 실패한 device 주소.
* FW allowlist 1:1 bond 정책에서 다른 central 이 붙어 있는 경우 발생.
* UI 가 이 값 non-null 시 "본딩 초기화" action 을 노출. 성공/타 에러로 clear. */
val bondConflictAddress = mutableStateOf<String?>(null)
// Callbacks
var onConnectionStateChanged: ((Boolean) -> Unit)? = null
@@ -1145,7 +1149,17 @@ class BleManager private constructor(private val context: Context) {
scheduleAutoReconnect()
} else {
isReconnecting.value = false
connectionError.value = "Connection failed (status $status). Try again."
// GATT status=5 (INSUFFICIENT_AUTHENTICATION) — FW allowlist 정책상
// 다른 central 과 이미 bond 중이면 peripheral 이 인증 거부.
val failedAddr = gatt.device.address
if (status == 5) {
bondConflictAddress.value = failedAddr
connectionError.value = "BOND_CONFLICT:$failedAddr"
debugLogger.warn("BOND_CONFLICT addr=$failedAddr — FW allowlist rejected (status 5)")
} else {
bondConflictAddress.value = null
connectionError.value = "Connection failed (status $status). Try again."
}
}
return@post
}
@@ -1157,6 +1171,7 @@ class BleManager private constructor(private val context: Context) {
isConnecting.value = false
isUserDisconnect = false
connectionError.value = null
bondConflictAddress.value = null // 연결 성공 시 클리어
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = true, isConnecting = false)
isConnected.value = true
@@ -68,6 +68,7 @@ fun DeviceScanView(appState: AppState) {
val isScanning by bleManager.isScanning
val bluetoothEnabled by bleManager.bluetoothEnabled
val connectionError by bleManager.connectionError
val bondConflictAddress by bleManager.bondConflictAddress
// BLE permissions
val requiredPermissions = remember {
@@ -225,22 +226,55 @@ fun DeviceScanView(appState: AppState) {
Spacer(modifier = Modifier.height(8.dp))
}
// Connection error
connectionError?.let { error ->
Row(
// Connection error — GATT status=5 (bond conflict) 시 별도 UX 안내 + reset action.
val curError = connectionError
val curBondAddr = bondConflictAddress
if (curError != null) {
val isBondConflict = curError.startsWith("BOND_CONFLICT:") && curBondAddr != null
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.Red)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
.padding(12.dp)
) {
Icon(Icons.Default.Close, stringResource(R.string.device_scan_error_desc), tint = Color.White)
Spacer(modifier = Modifier.width(12.dp))
Text(error, color = Color.White, fontSize = 13.sp, modifier = Modifier.weight(1f))
IconButton(onClick = { bleManager.connectionError.value = null }) {
Icon(Icons.Default.Close, stringResource(R.string.device_scan_dismiss_desc), tint = Color.White.copy(alpha = 0.8f))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Close, stringResource(R.string.device_scan_error_desc), tint = Color.White)
Spacer(modifier = Modifier.width(12.dp))
val displayText = if (isBondConflict)
stringResource(R.string.ble_error_bond_conflict)
else
curError
Text(displayText, color = Color.White, fontSize = 13.sp, modifier = Modifier.weight(1f))
IconButton(onClick = {
bleManager.connectionError.value = null
bleManager.bondConflictAddress.value = null
}) {
Icon(Icons.Default.Close, stringResource(R.string.device_scan_dismiss_desc), tint = Color.White.copy(alpha = 0.8f))
}
}
if (isBondConflict && curBondAddr != null) {
Spacer(modifier = Modifier.height(6.dp))
OutlinedButton(
onClick = {
bleManager.unbondAndRemoveAddress(curBondAddr)
bleManager.connectionError.value = null
bleManager.bondConflictAddress.value = null
android.widget.Toast.makeText(
context,
context.getString(R.string.ble_toast_bond_removed),
android.widget.Toast.LENGTH_LONG,
).show()
},
colors = ButtonDefaults.outlinedButtonColors(
contentColor = Color.White,
),
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White),
modifier = Modifier.align(Alignment.End),
) {
Text(stringResource(R.string.ble_action_reset_bond), fontSize = 12.sp)
}
}
}
Spacer(modifier = Modifier.height(8.dp))