feat(guardian): Rev2 · Bus 확장 + AlarmAggregator + NotificationSubscriber

GUARDIAN-01 §Rev2 개선 4건 (파트너 앱 개발 지원):

1. BleManager · Battery/BleConnection emit 지점 추가
   - emitBatteryEvent(pct): 임계 bucket 전이 (CRITICAL<5, LOW<20, OK≥20)
   - emitConnectionEvent(state, reason): CONNECTED / DISCONNECTED / FAILED
   - CONNECTED · STATE_DISCONNECTED · GATT_ERROR · 좀비 watchdog 4 경로 wire

2. AlarmAggregator (신규 · telemetry/) — subscriber 참조 구현
   - Bus 구독 · BvEvent/PostureEvent/GaitEvent/BleConn/Battery → AlarmEvent 재발행
   - 재귀 방지 (filterNot { it is AlarmEvent })
   - Category 매핑: BV_URGENT/BV_WARN/POSTURE_TRANSITION/GAIT_STARTED/STOPPED/
     BLE_LOST/BATTERY_CRITICAL/BATTERY_LOW
   - Guardian 앱 로직 단순화 (AlarmEvent 하나만 구독하면 됨)

3. Dev-mode overlay 확장 (PiezoMonitoringView · RSSI 옆)
   - ClinicalEventBus 최근 5개 이벤트 rolling 표시
   - Type(BV/PST/GAI/BLE/BAT/ALM) + severity color + 요약
   - LaunchedEffect + mutableStateListOf · isDevMode gate

4. NotificationSubscriber (신규 · telemetry/) — in-app 소비 참조 구현
   - AlarmEvent 구독 · Android push notification 발행
   - BATTERY_CRITICAL/LOW → device_status 채널 (신규)
   - BLE_LOST → 30초 debounce · CONNECTED 시 reset
   - BV_URGENT/WARN → AppState.updateLevelFromMeasurement 직접 호출과 중복 방지 · NO-OP
   - MainActivity.onCreate 에서 start(this) 1회 호출

NotificationService 확장:
   - device_status 채널 신규 (배터리·BLE 알람용)
   - sendBatteryNotification(level, isCritical) · bucket dedup
   - sendBleLostNotification(reason) · resetBleDebounce()

의도: Guardian 앱 (동업자 · Supabase 경유) 이 in-app subscriber 와 동일 패턴으로
소비하도록 참조. 다음 단계는 GUARDIAN-02 파트너용 스키마 문서 (cloud-mvp).
This commit is contained in:
2026-08-04 09:52:15 +09:00
parent f05068002b
commit de6e1d1931
6 changed files with 447 additions and 6 deletions
@@ -20,9 +20,17 @@ class NotificationService private constructor(private val context: Context) {
private const val CHANNEL_ID = "bladder_urgency"
private const val CHANNEL_NAME = "Bladder Monitoring"
private const val DEVICE_CHANNEL_ID = "device_status"
private const val BATTERY_NOTIF_ID = 1001
private const val BLE_NOTIF_ID = 1002
/** BLE 알람 최소 간격 (재연결/끊김 반복 시 스팸 방지) · ms */
private const val BLE_DEBOUNCE_MS = 30_000L
}
private var lastNotifiedUrgency: String? = null
// GUARDIAN-01 Phase D · 신규 채널 dedup key (battery bucket, BLE state).
@Volatile private var lastBatteryBucket: Int = -1
@Volatile private var lastBleAlertMs: Long = 0L
init {
createNotificationChannel()
@@ -30,16 +38,20 @@ class NotificationService private constructor(private val context: Context) {
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val bladder = NotificationChannel(
CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Bladder level urgency notifications"
enableVibration(true)
}
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
val device = NotificationChannel(
DEVICE_CHANNEL_ID, "Device Status", NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Battery low / BLE disconnect notifications"
}
nm.createNotificationChannel(bladder)
nm.createNotificationChannel(device)
}
}
@@ -70,8 +82,50 @@ class NotificationService private constructor(private val context: Context) {
} catch (_: SecurityException) { }
}
/** 배터리 임계 알림 · bucket 전이 시 1회만. isCritical=true → CRITICAL, false → LOW. */
fun sendBatteryNotification(level: Int, isCritical: Boolean) {
val bucket = if (isCritical) 0 else 1
if (bucket == lastBatteryBucket) return
lastBatteryBucket = bucket
val title = if (isCritical) "Battery Critical" else "Battery Low"
val body = "Device battery at $level%. Please charge soon."
val priority = if (isCritical) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT
val notif = NotificationCompat.Builder(context, DEVICE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(body)
.setPriority(priority)
.setAutoCancel(true)
.build()
try { NotificationManagerCompat.from(context).notify(BATTERY_NOTIF_ID, notif) }
catch (_: SecurityException) { }
}
/** BLE 연결 해제 알림 · 30초 내 중복 억제. */
fun sendBleLostNotification(reason: String?) {
val now = System.currentTimeMillis()
if (now - lastBleAlertMs < BLE_DEBOUNCE_MS) return
lastBleAlertMs = now
val notif = NotificationCompat.Builder(context, DEVICE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle("Device Disconnected")
.setContentText(if (reason != null) "BLE connection lost · $reason" else "BLE connection lost")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.build()
try { NotificationManagerCompat.from(context).notify(BLE_NOTIF_ID, notif) }
catch (_: SecurityException) { }
}
/** BLE 재연결 성공 시 debounce 리셋. */
fun resetBleDebounce() {
lastBleAlertMs = 0L
}
fun cancelAll() {
lastNotifiedUrgency = null
lastBatteryBucket = -1
lastBleAlertMs = 0L
NotificationManagerCompat.from(context).cancelAll()
}
}