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 c07675a..04707d9 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -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 ) diff --git a/app/src/main/java/com/medithings/vesiscan/ble/FastFailDetector.kt b/app/src/main/java/com/medithings/vesiscan/ble/FastFailDetector.kt new file mode 100644 index 0000000..7e3418f --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/ble/FastFailDetector.kt @@ -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 + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/services/ResetNeededStore.kt b/app/src/main/java/com/medithings/vesiscan/services/ResetNeededStore.kt new file mode 100644 index 0000000..f55a7b9 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/services/ResetNeededStore.kt @@ -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 { + 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) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY, JSONArray(set.sorted()).toString()) + .apply() + } +} 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 5f4b5e6..924fb61 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 @@ -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(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.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) + } } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 5c4af38..b65559f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -129,16 +129,15 @@ 추정 용량 - 페어링 해제 - %s 의 페어링을 해제합니다. 다시 사용하려면 PIN 재입력이 필요합니다. - 해제 + 저장된 기기 삭제 + %s 의 페어링을 폰과 기기 양쪽에서 지웁니다. 기기는 재부팅되고, 다시 쓰려면 새로 페어링합니다. + %s 을(를) 이 폰에서 지웁니다. 기기가 가까이 있어 접속해 기기 쪽 페어링도 함께 지우려 합니다(최대 7초). 닿지 않으면 폰에서만 지웁니다. + %s 을(를) 이 폰에서 지웁니다. 기기가 꺼져 있거나 멀어 기기 쪽 페어링은 남습니다. 이 기기를 다시(또는 다른 폰에서) 쓰려면 기기 버튼을 15초 눌러 초기화하세요. + 삭제 + 기기에 접속 중… + 기기 쪽 페어링도 함께 지우려 접속합니다(최대 7초). 닿지 않으면 폰에서만 지웁니다. + 기다리지 않고 삭제 - 기기 재연결 중… - 기기에 접속해 양쪽 페어링 정보를 함께 삭제합니다. 이 과정 없이 삭제하면 다음 페어링 시 기기 초기화가 필요할 수 있습니다. - 기기가 응답하지 않습니다 - 기기가 오프라인 상태입니다. 여기서 삭제하면 이 폰의 페어링 정보만 지워지며, 다음 페어링 시 기기 초기화가 필요합니다 (전원 15초 롱프레스).\n\n선택하세요: - 강제 삭제 - 기기 켜고 다시 시도 배뇨 알림 @@ -352,6 +351,10 @@ 이 기기는 다른 앱과 이미 페어링되어 있습니다. 기기 전원 버튼을 15초 롱프레스로 본딩 정보를 삭제한 뒤 다시 시도하세요. 이 폰의 본딩 정보 초기화 본딩 정보 삭제 완료. 다시 연결을 시도하세요. + + 이 폰에서 지웠습니다. 기기 쪽 페어링은 남아 있을 수 있어, 이 기기를 다시 쓰려면 기기 버튼을 15초 눌러 초기화한 뒤 새 기기로 페어링하세요. + 기기가 연결 직후 연결을 끊고 있습니다. 다른 폰과 페어링된 상태이거나 이 폰의 옛 페어링을 기기가 모르는 것입니다. 기기 버튼을 15초 눌러 초기화한 뒤 새 기기로 다시 페어링하세요. + 이 폰에서 지운 기기 — 기기를 15초 초기화하지 않았다면 연결이 거절됩니다 기기가 응답하지 않습니다. 전원 버튼을 15초 롱프레스로 재부팅한 뒤 다시 연결하세요. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 985cc7f..7860dac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -129,16 +129,15 @@ Estimated volume - Unpair Device - Unpair %s. PIN re-entry will be required to use it again. - Unpair + Delete saved device + Removes pairing for %s on both this phone and the device. The device reboots; pair again to use it. + 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. + 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. + Delete + Reaching the device… + Connecting to clear the device-side pairing as well (up to 7 s). If it cannot be reached, only this phone is cleared. + Delete without waiting - Reconnecting to device… - Contacting the device to clear pairing on both sides. This ensures re-pairing works without device reset. - Device is offline - 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: - Force delete - Turn on device & retry Voiding reminder @@ -352,6 +351,10 @@ This device is already paired with another app. Long-press the device power button for 15 seconds to clear its pairing, then try again. Reset Pairing (this phone) Pairing info removed. Please try connecting again. + + 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. + 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. + Removed from this phone — connection will be refused unless the device was reset (15 s) Device stopped responding. Long-press the device power button for 15 seconds to reboot, then reconnect. diff --git a/app/src/test/java/com/medithings/vesiscan/ble/FastFailDetectorTest.kt b/app/src/test/java/com/medithings/vesiscan/ble/FastFailDetectorTest.kt new file mode 100644 index 0000000..b6c4355 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/ble/FastFailDetectorTest.kt @@ -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)) + } +}