ble: 다중 기기 페어링 영속화 (KnownDeviceStore)

기존 단일 last_device_address/name 키를 JSON 리스트로 확장.
여러 각도 변형 기기를 등록해두고 PIN 재입력 없이 골라 연결 가능.

KnownDeviceStore (services/):
- getAll / add / remove / mostRecent
- 구 단일 키 자동 마이그레이션 (read 시 흡수, 새 add 시 호환 유지)

BleManager:
- 연결 성공 시 KnownDeviceStore.add 호출 (단일 키 직접 쓰기 제거)
- autoConnectLastDevice: mostRecent() 사용
- disconnectAndUnbond: known list 에서도 제거
- connectByAddress(addr,name) 신규 — 스캔 결과 없어도 OS bond 가
  있으면 바로 연결. KnownDeviceStore 에서 골라 호출.
- unbondAndRemoveAddress(addr) 신규 — 연결 안 된 기기도 페어링
  해제 가능 (removeBond + KnownDeviceStore.remove)
This commit is contained in:
2026-06-11 15:01:20 +09:00
parent 7fa18ec05a
commit b782db16b3
2 changed files with 147 additions and 13 deletions
@@ -246,14 +246,58 @@ class BleManager private constructor(private val context: Context) {
handler.postDelayed(timeout, 10000) handler.postDelayed(timeout, 10000)
} }
/**
* 특정 주소의 known device 페어링 해제 — 현재 연결된 기기가 아니어도 사용 가능.
* 연결된 상태라면 일반 disconnectAndUnbond() 를 사용.
*/
fun unbondAndRemoveAddress(address: String) {
// 현재 연결된 기기를 해제하려는 경우
if (bluetoothGatt?.device?.address == address) {
disconnectAndUnbond()
return
}
// 연결 안 된 known device — OS bond 만 끊고 list 에서 제거
val adapter = bluetoothAdapter ?: return
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull()
device?.let { dev ->
try {
val method = dev.javaClass.getMethod("removeBond")
method.invoke(dev)
logd { "removeBond called for ${dev.address}" }
} catch (e: Exception) {
logw { "removeBond failed: ${e.message}" }
}
}
com.example.medilightv2android.services.KnownDeviceStore.remove(context, address)
debugLogger.info("DEVICE_UNREGISTERED $address")
}
/**
* 저장된 기기 주소로 직접 연결. 스캔 결과에 안 잡혀도 OS bond 가 있으면 바로 됨.
* KnownDeviceStore 에서 골라 호출.
*/
fun connectByAddress(address: String, name: String) {
val adapter = bluetoothAdapter ?: run {
connectionError.value = "Bluetooth not available"
return
}
val device = try {
adapter.getRemoteDevice(address)
} catch (e: IllegalArgumentException) {
connectionError.value = "Invalid device address"
return
}
connect(BleDevice(device = device, rssi = 0))
// discoveredDevices 에 없을 수 있으므로 name 을 별도로 저장 (기존 connect 가 onConnected 시점에 name 설정)
connectedDeviceName.value = name
}
/** 저장된 마지막 기기에 자동 연결 시도 */ /** 저장된 마지막 기기에 자동 연결 시도 */
fun autoConnectLastDevice() { fun autoConnectLastDevice() {
if (isConnected.value) return if (isConnected.value) return
val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE) val recent = com.example.medilightv2android.services.KnownDeviceStore.mostRecent(context) ?: return
val address = prefs.getString("last_device_address", null) ?: return debugLogger.info("AUTO_CONNECT attempting ${recent.name} (${recent.address})")
val name = prefs.getString("last_device_name", null) ?: return lastConnectedAddress = recent.address
debugLogger.info("AUTO_CONNECT attempting $name ($address)")
lastConnectedAddress = address
scheduleAutoReconnect() scheduleAutoReconnect()
} }
@@ -308,10 +352,11 @@ class BleManager private constructor(private val context: Context) {
} }
} }
// 저장된 기기 정보 삭제 // 저장된 기기 정보 삭제 — known device list 에서도 제거
try { try {
val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE) device?.let { dev ->
prefs.edit().remove("last_device_address").remove("last_device_name").apply() com.example.medilightv2android.services.KnownDeviceStore.remove(context, dev.address)
}
} catch (_: Exception) {} } catch (_: Exception) {}
debugLogger.info("DEVICE_UNREGISTERED") debugLogger.info("DEVICE_UNREGISTERED")
@@ -809,11 +854,9 @@ class BleManager private constructor(private val context: Context) {
debugLogger.connected(connectedDeviceName.value, gatt.device.address) debugLogger.connected(connectedDeviceName.value, gatt.device.address)
try { try {
val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE) com.example.medilightv2android.services.KnownDeviceStore.add(
prefs.edit() context, gatt.device.address, connectedDeviceName.value
.putString("last_device_address", gatt.device.address) )
.putString("last_device_name", connectedDeviceName.value)
.apply()
} catch (_: Exception) {} } catch (_: Exception) {}
// MTU 247 요청 (reb: 208B 패킷 드롭 방지) // MTU 247 요청 (reb: 208B 패킷 드롭 방지)
@@ -0,0 +1,91 @@
package com.example.medilightv2android.services
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
/**
* 페어링했던 BLE 기기 다수 영속화.
* SharedPreferences "medilight_prefs" 의 "known_devices" 키에 JSON array.
*
* 기존 단일 키(last_device_address/last_device_name) 는 마이그레이션 위해 1회 읽어 합치고
* BleManager 가 새로 add() 호출 시 같이 정리.
*/
data class KnownDevice(
val address: String,
val name: String,
val lastConnectedAt: Long,
)
object KnownDeviceStore {
private const val PREFS = "medilight_prefs"
private const val KEY = "known_devices"
fun getAll(context: Context): List<KnownDevice> {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = prefs.getString(KEY, null)
val list = mutableListOf<KnownDevice>()
if (!raw.isNullOrBlank()) {
runCatching {
val arr = JSONArray(raw)
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
val addr = o.optString("address").takeIf { it.isNotBlank() } ?: continue
val name = o.optString("name", "Unknown")
val ts = o.optLong("lastConnectedAt", 0L)
list.add(KnownDevice(addr, name, ts))
}
}
}
// 구 키 마이그레이션 — 한 번이라도 들어와 보면 list 에 흡수
val legacyAddr = prefs.getString("last_device_address", null)
val legacyName = prefs.getString("last_device_name", null)
if (!legacyAddr.isNullOrBlank() && list.none { it.address == legacyAddr }) {
list.add(KnownDevice(legacyAddr, legacyName ?: "Unknown", System.currentTimeMillis()))
saveAll(context, list)
}
return list.sortedByDescending { it.lastConnectedAt }
}
/** 새 연결 성공 시 호출 — 있으면 lastConnectedAt 갱신, 없으면 추가. 구 단일 키도 같이 채움. */
fun add(context: Context, address: String, name: String) {
val current = getAll(context).toMutableList()
val idx = current.indexOfFirst { it.address == address }
val now = System.currentTimeMillis()
if (idx >= 0) current[idx] = current[idx].copy(name = name, lastConnectedAt = now)
else current.add(KnownDevice(address, name, now))
saveAll(context, current)
// 구 키도 호환성 유지
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString("last_device_address", address)
.putString("last_device_name", name)
.apply()
}
/** 페어링 해제 시 호출 — known list 에서 제거. */
fun remove(context: Context, address: String) {
val current = getAll(context).filter { it.address != address }
saveAll(context, current)
// 구 키 정리 — 제거한 게 마지막 기기였다면
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (prefs.getString("last_device_address", null) == address) {
prefs.edit().remove("last_device_address").remove("last_device_name").apply()
}
}
fun mostRecent(context: Context): KnownDevice? = getAll(context).firstOrNull()
private fun saveAll(context: Context, list: List<KnownDevice>) {
val arr = JSONArray()
list.forEach { d ->
arr.put(JSONObject().apply {
put("address", d.address)
put("name", d.name)
put("lastConnectedAt", d.lastConnectedAt)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(KEY, arr.toString())
.apply()
}
}