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:
2026-10-08 13:53:43 +09:00
parent 1592a1454e
commit 92852a1168
7 changed files with 355 additions and 64 deletions
@@ -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
)
@@ -0,0 +1,50 @@
/*
* FastFailDetector — 연결이 성립된 직후 끊기는 일이 반복되는지 본다. 본드 불일치 의심.
* 사용자 앱(vesiscan_android_user · ble/FastFailDetector.kt) 과 같은 파일이다.
*
* ## 왜 (2026-10-07 실측)
* 기기가 다른 폰의 본드를 들고 있으면(또는 이 폰의 옛 본드를 모르면) 링크는 붙고 0.05초 뒤
* 기기가 인증 실패(HCI 0x05)로 끊는다. 안드로이드는 이것을 GATT status 133 으로만 알려 주고
* 이유는 숨긴다. 그래서 화면에는 일반 "연결이 끊겼습니다"만 떴고, 사용자는 폰 쪽을 몇 번
* 지워 봐도(기기 쪽이 문제라) 풀리지 않았다. 정작 필요한 조치는 기기 15초 초기화다.
*
* ## 판정
* connectGatt 호출 뒤 [fastMs] 안에 실패한 것이 같은 주소로 [needed]번 연속이면 의심한다.
* 느린 실패(타임아웃 · 거리)는 다른 원인이므로 연속을 끊는다. 걸린 시간을 모르면(0) 판정하지
* 않는다. 순수 클래스라 JVM 시험(FastFailDetectorTest)으로 잠근다.
*/
package com.medithings.vesiscan.ble
class FastFailDetector(
private val fastMs: Long = FAST_MS,
private val needed: Int = NEEDED,
private val windowMs: Long = WINDOW_MS,
) {
private var address: String? = null
private var count = 0
private var lastAtMs = 0L
/** 연결 실패 1건. 의심 문턱에 닿았으면 true. */
fun onFailure(address: String, elapsedMs: Long, nowMs: Long): Boolean {
if (elapsedMs <= 0L || elapsedMs > fastMs) { reset(); return false }
if (!address.equals(this.address, ignoreCase = true) || nowMs - lastAtMs > windowMs) {
this.address = address
count = 0
}
lastAtMs = nowMs
count++
return count >= needed
}
/** 연결이 제대로 됐거나 새 본드가 성립했다 — 연속을 끊는다. */
fun reset() { address = null; count = 0; lastAtMs = 0L }
companion object {
/** 이 안에 끊기면 "성립 직후"로 본다. 실측 0.05초 · 직접 연결 타임아웃은 수십 초다. */
const val FAST_MS = 1_500L
/** 한 번은 우연일 수 있다. 두 번이면 패턴이다. */
const val NEEDED = 2
/** 이보다 오래 떨어진 실패는 다른 사건이다. */
const val WINDOW_MS = 60_000L
}
}
@@ -0,0 +1,48 @@
/*
* ResetNeededStore — 폰에서만 페어링을 지운 기기 주소.
*
* ## 왜 (2026-10-08 · 사용자 앱 e7189afb 와 같은 변경)
* 저장된 기기를 지울 때 기기가 연결돼 있지 않으면 앱은 폰 쪽 본드만 지울 수 있다. 기기는
* 옛 본드를 들고 있어 다음 연결을 거절한다(2026-10-07 실측: 링크 성립 0.05초 뒤 인증 실패로
* 끊음). 그 기기가 다시 "새 기기" 목록에 보일 때 아무 표시가 없으면 사용자는 눌러 보고
* 실패하고 또 눌러 본다. 지운 주소를 기억해 두면 그 자리에서 "기기 15초 초기화가 필요할 수
* 있다"를 말할 수 있다.
*
* ## 언제 지우나
* 그 주소와 GATT 연결이 성립하면(기기가 링크를 받아 줬다) 지운다 — BleManager 의 연결 성공
* 분기가 부른다. 사용자가 기기를 초기화했는지 앱은 알 수 없으니 표시는 "필요할 수 있다"로만
* 말한다. KnownDeviceStore 와 같은 prefs 파일을 쓴다.
*/
package com.medithings.vesiscan.services
import android.content.Context
import org.json.JSONArray
object ResetNeededStore {
private const val PREFS = "medilight_prefs"
private const val KEY = "needs_device_reset"
fun all(context: Context): Set<String> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, null)
?: return emptySet()
return runCatching {
val arr = JSONArray(raw)
(0 until arr.length()).mapNotNull { arr.optString(it).takeIf { s -> s.isNotBlank() } }.toSet()
}.getOrDefault(emptySet())
}
/** 폰에서만 지웠다 — 기기 쪽 페어링이 남아 있을 수 있다. */
fun mark(context: Context, address: String) = save(context, all(context) + address.uppercase())
/** 기기가 연결을 받아 줬다 — 더 이상 표시하지 않는다. */
fun clear(context: Context, address: String) {
val cur = all(context)
if (address.uppercase() in cur) save(context, cur - address.uppercase())
}
private fun save(context: Context, set: Set<String>) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(KEY, JSONArray(set.sorted()).toString())
.apply()
}
}
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.BluetoothConnected
@@ -41,6 +42,7 @@ import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.models.SensorMode
import com.medithings.vesiscan.services.KnownDevice
import com.medithings.vesiscan.services.KnownDeviceStore
import com.medithings.vesiscan.services.ResetNeededStore
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
import com.medithings.vesiscan.ui.theme.*
import java.text.SimpleDateFormat
@@ -53,8 +55,18 @@ import java.util.Locale
*/
private const val SPINNER_TIMEOUT_MS = 25_000L
/** 저장된 기기 삭제 다이얼로그 상태 (2026-08-04 · BLE bonding 대칭성 fix). */
private enum class UnpairPhase { Confirm, Reconnecting, OfflineWarning }
/** 저장된 기기 삭제 다이얼로그 상태. Reaching = 방금 광고를 본 기기에 짧게 접속 중(2026-10-08). */
private enum class UnpairPhase { Confirm, Reaching }
/** 삭제 시 "근처"로 보는 광고 신선도 — 이 안에 광고를 봤으면 접속해 기기 쪽도 지우려 한다. */
private const val UNPAIR_ADVERT_FRESH_MS = 5_000L
/**
* 삭제 시 기기 쪽 페어링도 지우려 접속을 기다리는 상한. 방금 광고를 본 기기만 시도하므로
* 광고 대기 없이 바로 붙는다 — 연결·서비스 준비까지 보통 2~5초다. 종전 14초는 "이미 안 붙는
* 기기를 지우는" 흔한 경우에 통째로 낭비됐다(2026-10-06 · 10-07).
*/
private const val UNPAIR_REACH_MS = 7_000L
@Composable
fun DeviceScanView(appState: AppState) {
@@ -65,6 +77,8 @@ fun DeviceScanView(appState: AppState) {
// 2026-07-10: clinical 흐름 조건 제거 — 일반 사용자도 저장된 기기 목록 표시.
var knownDevices by remember { mutableStateOf(KnownDeviceStore.getAll(context)) }
// 폰에서만 지운 기기 — 새 기기 목록에 다시 보이면 "초기화 필요할 수 있음"을 붙인다.
val resetNeeded = ResetNeededStore.all(context)
var pendingDelete by remember { mutableStateOf<KnownDevice?>(null) }
val knownAddresses = knownDevices.map { it.address }.toSet()
val newDevices = bleManager.discoveredDevices
@@ -269,6 +283,10 @@ fun DeviceScanView(appState: AppState) {
val isUnbondUnreachable = curError == "UNBOND_UNREACHABLE"
// 광고가 없어 연결을 시도하지 않은 경우. 반쪽 연결을 애초에 막는다.
val isNotAdvertising = curError == "DEVICE_NOT_ADVERTISING"
// 삭제가 폰에서만 됐다는 **알림**(오류가 아니다 · 2026-10-08). 기기 쪽 페어링이 남을 수 있다.
val isUnpairedPhoneOnly = curError.startsWith("UNPAIRED_PHONE_ONLY:")
// 연결 직후 끊김 반복 — 기기가 다른 본드를 들고 있는 것으로 의심(2026-10-07 실측).
val isBondMismatch = curError.startsWith("BOND_MISMATCH_SUSPECTED:")
// 안드로이드 스캔 빈도 제한. 기기 탓으로 오해하지 않게 남은 초를 적어 준다.
val throttledSec = curError.removePrefix("SCAN_THROTTLED:")
.takeIf { curError.startsWith("SCAN_THROTTLED:") }
@@ -277,13 +295,19 @@ fun DeviceScanView(appState: AppState) {
.fillMaxWidth()
.padding(horizontal = 24.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.Red)
.background(if (isUnpairedPhoneOnly) MlPrimary else Color.Red)
.padding(12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Close, stringResource(R.string.device_scan_error_desc), tint = Color.White)
Icon(
if (isUnpairedPhoneOnly) Icons.Default.Info else Icons.Default.Close,
stringResource(R.string.device_scan_error_desc),
tint = Color.White,
)
Spacer(modifier = Modifier.width(12.dp))
val displayText = when {
isUnpairedPhoneOnly -> stringResource(R.string.ble_notice_unpaired_phone_only)
isBondMismatch -> stringResource(R.string.ble_error_bond_mismatch_suspected)
isBondConflict -> stringResource(R.string.ble_error_bond_conflict)
isDeviceUnresponsive -> stringResource(R.string.ble_error_device_unresponsive)
isReconnectGaveUp -> stringResource(R.string.ble_error_reconnect_gave_up)
@@ -302,7 +326,11 @@ fun DeviceScanView(appState: AppState) {
Icon(Icons.Default.Close, stringResource(R.string.device_scan_dismiss_desc), tint = Color.White.copy(alpha = 0.8f))
}
}
if (isBondConflict && curBondAddr != null) {
// 불일치 의심일 때는 폰에 본드가 **남아 있을 때만** 초기화 버튼을 준다 — 이미 없으면
// 폰 쪽에서 할 일이 없고, 할 일은 기기 15초 초기화다.
val offerPhoneReset = curBondAddr != null &&
(isBondConflict || (isBondMismatch && bleManager.isAddressBonded(curBondAddr)))
if (offerPhoneReset && curBondAddr != null) {
Spacer(modifier = Modifier.height(6.dp))
OutlinedButton(
onClick = {
@@ -412,62 +440,89 @@ fun DeviceScanView(appState: AppState) {
onClick = {
connectingDeviceAddress = device.address
bleManager.connect(device)
}
},
hint = if (device.address.uppercase() in resetNeeded)
stringResource(R.string.scan_hint_needs_reset) else null,
)
}
}
}
}
// Delete Known Device — 2단계 다이얼로그 (2026-08-04 · BLE bonding 대칭성 fix).
var unpairPhase by remember { mutableStateOf<UnpairPhase>(UnpairPhase.Confirm) }
// 저장된 기기 삭제 (2026-10-08 재설계 · 사용자 앱 e7189afb 와 같은 흐름).
//
// 종전(2026-08-04)은 "확인 → 14초 재연결 시도 → 실패 시 강제 삭제" 세 단계였다. 지우려는
// 기기는 대개 **이미 연결이 안 되는** 기기라 그 14초는 정의상 실패했고, 사용자에게는
// "삭제가 안 된다"로 읽혔다(2026-10-06 · 10-07 임상 폰). 이제
// · 삭제는 **항상 즉시** 폰 쪽을 지운다. 기다리는 단계가 기본 경로에 없다.
// · 지금 연결돼 있으면 기기 쪽도 같이 지운다(msr?). 사용자가 고를 일이 없다.
// · 방금 광고를 본 기기만 짧게(UNPAIR_REACH_MS) 접속해 양쪽을 지우려 하고, 그 사이
// [기다리지 않고 삭제] 가 있다. 닿지 않으면 폰에서만 지우고 그 사실을 알린다.
// · 폰에서만 지운 주소는 기억해 두고(ResetNeededStore), 새 기기 목록에 다시 보일 때
// "기기 초기화가 필요할 수 있음"을 붙인다.
var unpairPhase by remember { mutableStateOf(UnpairPhase.Confirm) }
pendingDelete?.let { target ->
val connectedNow = bleManager.isServiceReady.value &&
target.address.equals(bleManager.lastConnectedAddress, ignoreCase = true)
val reachable = !connectedNow &&
(bleManager.lastSeenAgoMs(target.address) ?: Long.MAX_VALUE) <= UNPAIR_ADVERT_FRESH_MS
// 목록에서 바로 빼서 보여 준다 — 저장소 갱신은 BleManager 쪽에서 조금 뒤에 끝날 수 있다.
fun dropFromList() {
knownDevices = KnownDeviceStore.getAll(context).filter { it.address != target.address }
pendingDelete = null
unpairPhase = UnpairPhase.Confirm
}
// 폰에서만 지운다 — 즉시. 기기 쪽이 남을 수 있다는 것을 알린다(오류가 아닌 알림).
fun deletePhoneOnly() {
bleManager.unbondAndRemoveAddress(target.address)
dropFromList()
bleManager.bondConflictAddress.value = null
bleManager.connectionError.value = "UNPAIRED_PHONE_ONLY:${target.address}"
}
when (unpairPhase) {
UnpairPhase.Confirm -> AlertDialog(
onDismissRequest = { pendingDelete = null; unpairPhase = UnpairPhase.Confirm },
onDismissRequest = { pendingDelete = null },
title = { Text(stringResource(R.string.unpair_title)) },
text = { Text(stringResource(R.string.unpair_message, target.name)) },
text = {
Text(
stringResource(
when {
connectedNow -> R.string.unpair_message_connected
reachable -> R.string.unpair_message_reach
else -> R.string.unpair_message_offline
},
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 },
)
if (connectedNow || reachable) {
if (!connectedNow) unpairPhase = UnpairPhase.Reaching
bleManager.unbondSmart(
address = target.address,
timeoutMs = UNPAIR_REACH_MS,
onSuccess = { dropFromList() },
onDeviceOffline = { deletePhoneOnly() },
)
} else {
deletePhoneOnly()
}
}) { 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)) },
UnpairPhase.Reaching -> AlertDialog(
onDismissRequest = { /* 짧은 대기 · 아래 버튼으로만 끝낸다 */ },
title = { Text(stringResource(R.string.unpair_reaching_title)) },
text = { Text(stringResource(R.string.unpair_reaching_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) }
TextButton(onClick = { bleManager.abortUnbondSmart() }) {
Text(stringResource(R.string.unpair_skip_wait))
}
},
dismissButton = {
TextButton(onClick = {
unpairPhase = UnpairPhase.Confirm
}) { Text(stringResource(R.string.unpair_retry_connect)) }
}
)
}
}
@@ -584,7 +639,14 @@ private fun KnownDeviceRow(
}
@Composable
private fun DeviceRow(device: BleDevice, isConnecting: Boolean, enabled: Boolean, onClick: () -> Unit) {
private fun DeviceRow(
device: BleDevice,
isConnecting: Boolean,
enabled: Boolean,
onClick: () -> Unit,
/** 신호 세기 자리에 대신 보여 줄 한 줄(폰에서만 지운 기기 안내 등). */
hint: String? = null,
) {
Card(
modifier = Modifier
.fillMaxWidth()
@@ -611,10 +673,14 @@ private fun DeviceRow(device: BleDevice, isConnecting: Boolean, enabled: Boolean
Column(modifier = Modifier.weight(1f)) {
Text(device.name, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Row(verticalAlignment = Alignment.CenterVertically) {
SignalBars(rssi = device.rssi)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(signalLabelRes(device.rssi)), fontSize = 12.sp, color = MlSecondaryText)
if (hint != null) {
Text(hint, fontSize = 12.sp, color = MlPrimary)
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
SignalBars(rssi = device.rssi)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(signalLabelRes(device.rssi)), fontSize = 12.sp, color = MlSecondaryText)
}
}
}
+12 -9
View File
@@ -129,16 +129,15 @@
<string name="estimated_volume">추정 용량</string>
<!-- Device Scan -->
<string name="unpair_title">페어링 해제</string>
<string name="unpair_message">%s 의 페어링을 해제합니다. 다시 사용하려면 PIN 재입력이 필요합니다.</string>
<string name="unpair_confirm">해제</string>
<string name="unpair_title">저장된 기기 삭제</string>
<string name="unpair_message_connected">%s 의 페어링을 폰과 기기 양쪽에서 지웁니다. 기기는 재부팅되고, 다시 쓰려면 새로 페어링합니다.</string>
<string name="unpair_message_reach">%s 을(를) 이 폰에서 지웁니다. 기기가 가까이 있어 접속해 기기 쪽 페어링도 함께 지우려 합니다(최대 7초). 닿지 않으면 폰에서만 지웁니다.</string>
<string name="unpair_message_offline">%s 을(를) 이 폰에서 지웁니다. 기기가 꺼져 있거나 멀어 기기 쪽 페어링은 남습니다. 이 기기를 다시(또는 다른 폰에서) 쓰려면 기기 버튼을 15초 눌러 초기화하세요.</string>
<string name="unpair_confirm">삭제</string>
<string name="unpair_reaching_title">기기에 접속 중…</string>
<string name="unpair_reaching_desc">기기 쪽 페어링도 함께 지우려 접속합니다(최대 7초). 닿지 않으면 폰에서만 지웁니다.</string>
<string name="unpair_skip_wait">기다리지 않고 삭제</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>
@@ -352,6 +351,10 @@
<string name="ble_error_bond_conflict">이 기기는 다른 앱과 이미 페어링되어 있습니다. 기기 전원 버튼을 15초 롱프레스로 본딩 정보를 삭제한 뒤 다시 시도하세요.</string>
<string name="ble_action_reset_bond">이 폰의 본딩 정보 초기화</string>
<string name="ble_toast_bond_removed">본딩 정보 삭제 완료. 다시 연결을 시도하세요.</string>
<!-- 2026-10-08 삭제 흐름 재설계 — 폰에서만 지운 알림 · 연결 직후 끊김 반복(본드 불일치 의심) · 새 기기 힌트 -->
<string name="ble_notice_unpaired_phone_only">이 폰에서 지웠습니다. 기기 쪽 페어링은 남아 있을 수 있어, 이 기기를 다시 쓰려면 기기 버튼을 15초 눌러 초기화한 뒤 새 기기로 페어링하세요.</string>
<string name="ble_error_bond_mismatch_suspected">기기가 연결 직후 연결을 끊고 있습니다. 다른 폰과 페어링된 상태이거나 이 폰의 옛 페어링을 기기가 모르는 것입니다. 기기 버튼을 15초 눌러 초기화한 뒤 새 기기로 다시 페어링하세요.</string>
<string name="scan_hint_needs_reset">이 폰에서 지운 기기 — 기기를 15초 초기화하지 않았다면 연결이 거절됩니다</string>
<!-- Device unresponsive (GATT status 8/19/22 · FW hang) — 2026-08-04 -->
<string name="ble_error_device_unresponsive">기기가 응답하지 않습니다. 전원 버튼을 15초 롱프레스로 재부팅한 뒤 다시 연결하세요.</string>
<!-- Reconnect give-up (MAX_RECONNECT_ATTEMPTS reached) -->
+12 -9
View File
@@ -129,16 +129,15 @@
<string name="estimated_volume">Estimated volume</string>
<!-- Device Scan -->
<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>
<string name="unpair_title">Delete saved device</string>
<string name="unpair_message_connected">Removes pairing for %s on both this phone and the device. The device reboots; pair again to use it.</string>
<string name="unpair_message_reach">Removes %s from this phone. The device is nearby, so the app will try to connect and clear the device-side pairing too (up to 7 s). If it cannot reach the device, only this phone is cleared.</string>
<string name="unpair_message_offline">Removes %s from this phone. The device is off or out of range, so its own pairing remains. To use it again (or on another phone), hold the device button for 15 s to reset it.</string>
<string name="unpair_confirm">Delete</string>
<string name="unpair_reaching_title">Reaching the device…</string>
<string name="unpair_reaching_desc">Connecting to clear the device-side pairing as well (up to 7 s). If it cannot be reached, only this phone is cleared.</string>
<string name="unpair_skip_wait">Delete without waiting</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>
@@ -352,6 +351,10 @@
<string name="ble_error_bond_conflict">This device is already paired with another app. Long-press the device power button for 15 seconds to clear its pairing, then try again.</string>
<string name="ble_action_reset_bond">Reset Pairing (this phone)</string>
<string name="ble_toast_bond_removed">Pairing info removed. Please try connecting again.</string>
<!-- 2026-10-08 삭제 흐름 재설계 — 폰에서만 지운 알림 · 연결 직후 끊김 반복(본드 불일치 의심) · 새 기기 힌트 -->
<string name="ble_notice_unpaired_phone_only">Removed from this phone. The device may still hold its pairing; to use it again, hold the device button for 15 s to reset it, then pair it as a new device.</string>
<string name="ble_error_bond_mismatch_suspected">The device drops the connection right after connecting. It is paired with another phone, or no longer knows this phone\'s old pairing. Hold the device button for 15 s to reset it, then pair again as a new device.</string>
<string name="scan_hint_needs_reset">Removed from this phone — connection will be refused unless the device was reset (15 s)</string>
<!-- Device unresponsive (GATT status 8/19/22 · FW hang) — 2026-08-04 -->
<string name="ble_error_device_unresponsive">Device stopped responding. Long-press the device power button for 15 seconds to reboot, then reconnect.</string>
<!-- Reconnect give-up (MAX_RECONNECT_ATTEMPTS reached) -->
@@ -0,0 +1,59 @@
package com.medithings.vesiscan.ble
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/** 연결 직후 끊김 반복 → 본드 불일치 의심. 2026-10-07 실측(0.05초 뒤 끊김 8회)을 잠근다. */
class FastFailDetectorTest {
private val a = "DD:5E:1E:39:81:3E"
private val b = "E7:17:AF:06:6A:FB"
@Test
fun `two fast failures in a row on the same address trip it`() {
val d = FastFailDetector()
assertFalse(d.onFailure(a, elapsedMs = 50, nowMs = 1_000))
assertTrue(d.onFailure(a, elapsedMs = 60, nowMs = 2_500))
}
@Test
fun `a slow failure breaks the streak`() {
val d = FastFailDetector()
assertFalse(d.onFailure(a, elapsedMs = 50, nowMs = 1_000))
assertFalse(d.onFailure(a, elapsedMs = 30_000, nowMs = 32_000))
assertFalse(d.onFailure(a, elapsedMs = 50, nowMs = 33_000))
assertTrue(d.onFailure(a, elapsedMs = 50, nowMs = 34_000))
}
@Test
fun `unknown elapsed never trips`() {
val d = FastFailDetector()
assertFalse(d.onFailure(a, elapsedMs = 0, nowMs = 1_000))
assertFalse(d.onFailure(a, elapsedMs = 0, nowMs = 2_000))
assertFalse(d.onFailure(a, elapsedMs = 50, nowMs = 3_000))
}
@Test
fun `another address starts over, case-insensitively the same one does not`() {
val d = FastFailDetector()
assertFalse(d.onFailure(a, elapsedMs = 50, nowMs = 1_000))
assertFalse(d.onFailure(b, elapsedMs = 50, nowMs = 2_000))
assertTrue(d.onFailure(b.lowercase(), elapsedMs = 50, nowMs = 3_000))
}
@Test
fun `failures far apart are separate events`() {
val d = FastFailDetector()
assertFalse(d.onFailure(a, elapsedMs = 50, nowMs = 1_000))
assertFalse(d.onFailure(a, elapsedMs = 50, nowMs = 1_000 + FastFailDetector.WINDOW_MS + 1))
}
@Test
fun `reset clears the streak`() {
val d = FastFailDetector()
assertFalse(d.onFailure(a, elapsedMs = 50, nowMs = 1_000))
d.reset()
assertFalse(d.onFailure(a, elapsedMs = 50, nowMs = 2_000))
}
}