feat(longrun): 장시간 측정 중단 감지 + 설정에 준비 점검 항목

같은 빌드가 태블릿 34시간 / Xiaomi 40분 이었던 문제 대응.
앱 구조(Foreground Service + connectedDevice + WakeLock)는 이미 맞았고
제조사 배터리 관리자가 프로세스를 죽인 것이라, 구조 변경이 아니라
감지와 안내로 접근한다.

- LongRunGuard: 측정 시작/생존/정상종료를 기록해 비정상 종료를 감지.
  다음 실행 때 "언제부터 언제까지 몇 분" 을 알려준다. 제조사 무관하게 동작.
- BLE 로그 헤더에 기기/제한 상태 진단줄 (실패 후 원인 판별용).
- 설정 패널에 "장시간 측정 준비" — 배터리 최적화·미사용앱 제한 상태 확인 및
  해제 진입. 자동시작은 상태를 읽을 수 없어 안내만 제공.

측정 시작을 모달로 막지 않는다. 초안에서는 Auto Scan 에 사전점검을 걸었는데
OEM_AUTOSTART 를 제조사 이름만으로 판단해서(상태 조회 API 가 없음) 설정을
완벽히 해둔 기기에도 영원히 뜨는 문제가 있었다. 고칠 수도 없는 항목으로
측정을 가로막으면 경고가 무시된다. 추측으로 경고하지 않고, 실제로 중단됐을
때만 알린다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-24 11:13:46 +09:00
parent a86fefe321
commit f025bbc0a3
6 changed files with 587 additions and 0 deletions
@@ -125,6 +125,9 @@ class BleDebugLogger private constructor(private val context: Context) {
w.write("=== VesiScan BLE Debug Log ===\n")
w.write("Process start: ${dateFmt.format(Date(processStart))}\n")
w.write("File: ${f.absolutePath}\n")
// 기기/제조사 제한 상태 — 장시간 측정이 중간에 죽었을 때 원인 판별용.
// (같은 빌드가 태블릿 34시간 / Xiaomi 40분 이었던 사례)
w.write(LongRunGuard.diagnosticLine(context) + "\n")
w.write("=".repeat(60) + "\n")
w.flush()
return w
@@ -0,0 +1,225 @@
package com.medithings.vesiscan.services
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* 장시간 측정 보호 — **중단 감지**와 **OS/제조사 제한 사전점검**.
*
* ## 왜 필요한가
* 2026-08-24 실측: 같은 빌드가 태블릿에서 **34시간** 정상 동작했는데 Xiaomi 폰에서는
* **40분** 만에 죽었다. 즉 앱 구조(Foreground Service + connectedDevice + WakeLock)의
* 문제가 아니라 **제조사 배터리 관리자**가 프로세스를 죽인 것이다.
*
* 여기서 하는 일은 두 가지고, 성격이 다르다.
*
* | | 목적 | 제조사 의존 |
* |---|---|---|
* | [markMeasurementStart] / [markCleanStop] / [consumeInterruption] | 죽은 걸 **감지**해서 사용자에게 알림 | 없음 — 항상 동작 |
* | [checkRestrictions] / [resolveIntentFor] | 죽을 확률을 **낮춤** | 있음 — 일부는 안내만 가능 |
*
* 설정 안내는 실패 확률을 낮출 뿐이고, 감지는 실패를 놓치지 않게 한다. 둘 다 필요하다.
* 특히 의료기기에서 **측정이 안 됐는데 된 줄 아는 상황**이 제일 위험하다.
*/
object LongRunGuard {
private const val PREF = "vesiscan_longrun"
private const val KEY_START_MS = "measure_start_ms"
private const val KEY_LAST_ALIVE_MS = "measure_last_alive_ms"
private const val KEY_DEVICE = "measure_device"
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREF, Context.MODE_PRIVATE)
// ── 중단 감지 ──────────────────────────────────────────────────────────
/**
* 측정을 시작했다고 기록한다. 정상 종료 시 [markCleanStop] 이 이 기록을 지운다.
* 앱이 죽으면 기록이 남고, 다음 실행에서 [consumeInterruption] 이 그걸 찾아낸다.
*/
fun markMeasurementStart(context: Context, deviceName: String) {
val now = System.currentTimeMillis()
prefs(context).edit()
.putLong(KEY_START_MS, now)
.putLong(KEY_LAST_ALIVE_MS, now)
.putString(KEY_DEVICE, deviceName)
.apply()
}
/**
* 측정이 살아 있음을 갱신한다. 측정 루프에서 주기적으로 부른다.
*
* 시작 시각만 기록하면 "언제까지 살아 있었는지"를 모른다. 이 값이 있어야
* "0시 12분 ~ 0시 52분(40분)" 처럼 **언제 끊겼는지**를 알려줄 수 있다.
*/
fun heartbeat(context: Context) {
val p = prefs(context)
if (p.getLong(KEY_START_MS, 0L) == 0L) return
p.edit().putLong(KEY_LAST_ALIVE_MS, System.currentTimeMillis()).apply()
}
/** 사용자가 정상적으로 측정을 멈췄다. 중단 기록을 지운다. */
fun markCleanStop(context: Context) {
prefs(context).edit()
.remove(KEY_START_MS)
.remove(KEY_LAST_ALIVE_MS)
.remove(KEY_DEVICE)
.apply()
}
data class Interruption(
val startMs: Long,
val lastAliveMs: Long,
val deviceName: String,
) {
val durationMin: Long get() = (lastAliveMs - startMs) / 60_000
private val fmt get() = SimpleDateFormat("M/d HH:mm", Locale.getDefault())
val startText: String get() = fmt.format(Date(startMs))
val endText: String get() = fmt.format(Date(lastAliveMs))
}
/**
* 지난 실행이 비정상 종료됐으면 그 정보를 돌려주고 **기록을 지운다**(한 번만 알림).
* 정상 종료였거나 측정 이력이 없으면 null.
*/
fun consumeInterruption(context: Context): Interruption? {
val p = prefs(context)
val start = p.getLong(KEY_START_MS, 0L)
if (start == 0L) return null
val lastAlive = p.getLong(KEY_LAST_ALIVE_MS, start)
val device = p.getString(KEY_DEVICE, "") ?: ""
markCleanStop(context)
// 1분 미만은 앱 재시작 등 노이즈로 보고 알리지 않는다.
if (lastAlive - start < 60_000) return null
return Interruption(start, lastAlive, device)
}
// ── OS/제조사 제한 사전점검 ─────────────────────────────────────────────
enum class Restriction {
/** Doze/앱 대기 버킷 제외. 시스템 다이얼로그 한 번으로 해제된다. */
BATTERY_OPTIMIZATION,
/** "사용하지 않는 경우 앱 활동 일시정지". 설정 화면으로 보낼 수 있다. */
APP_HIBERNATION,
/**
* 제조사 자동시작/백그라운드 허용. **공개 API 가 없다.**
* 상태를 읽을 수도, 해제할 수도 없어서 안내만 가능하다.
*/
OEM_AUTOSTART,
}
/**
* **상태를 실제로 확인할 수 있는** 제한 중 지금 걸려 있는 것만 돌려준다.
*
* ⚠️ [Restriction.OEM_AUTOSTART] 는 여기 넣지 않는다. 제조사 자동시작은 상태를
* 읽을 방법이 없어서, 넣으면 "샤오미면 무조건 경고"가 되어 설정을 완벽히 해둔
* 사용자에게도 영원히 뜬다. 고칠 수도 없는 걸 계속 띄우면 늑대소년이 된다.
* 자동시작은 사용자가 [Restriction] 목록이 아니라 준비 화면에서 안내로만 본다.
*/
fun checkRestrictions(context: Context): List<Restriction> {
val out = mutableListOf<Restriction>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
if (pm != null && !pm.isIgnoringBatteryOptimizations(context.packageName)) {
out += Restriction.BATTERY_OPTIMIZATION
}
}
// API 30+ 의 "미사용 앱 제한". 상태를 직접 못 읽는 기기가 있어 예외는 무시하고
// 확인 불가면 항목을 넣지 않는다 (없는 문제를 띄우지 않기 위해).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try {
val whitelisted = context.packageManager.isAutoRevokeWhitelisted
if (!whitelisted) out += Restriction.APP_HIBERNATION
} catch (_: Throwable) { /* 확인 불가 → 건너뜀 */ }
}
return out
}
/**
* 배터리 관리가 공격적인 것으로 알려진 제조사.
* 여기 없다고 안전하다는 뜻은 아니고, **안내를 띄울 가치가 있는지**의 기준이다.
*/
fun isAggressiveOem(): Boolean {
val m = Build.MANUFACTURER.lowercase(Locale.US)
return m.contains("xiaomi") || m.contains("redmi") || m.contains("poco") ||
m.contains("oppo") || m.contains("realme") || m.contains("vivo") ||
m.contains("oneplus") || m.contains("huawei") || m.contains("honor") ||
m.contains("samsung") || m.contains("meizu") || m.contains("asus")
}
/**
* 해당 항목을 해제할 수 있는 화면 Intent. 없으면 null (= 안내만 가능).
*
* ⚠️ [Restriction.OEM_AUTOSTART] 는 제조사 비공개 화면이라 기종/버전에 따라 실패한다.
* 실패해도 앱이 죽지 않도록 호출부에서 반드시 try/catch 하고,
* 실패 시 앱 상세정보 화면으로 폴백한다 ([appDetailsIntent]).
*/
@android.annotation.SuppressLint("BatteryLife")
fun resolveIntentFor(context: Context, r: Restriction): Intent? = when (r) {
Restriction.BATTERY_OPTIMIZATION ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}")
}
} else null
Restriction.APP_HIBERNATION -> appDetailsIntent(context)
Restriction.OEM_AUTOSTART -> oemAutostartIntent() ?: appDetailsIntent(context)
}
fun appDetailsIntent(context: Context): Intent =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${context.packageName}")
}
/** 제조사 자동시작 설정 화면 (비공식). 열리지 않으면 호출부가 폴백한다. */
private fun oemAutostartIntent(): Intent? {
val m = Build.MANUFACTURER.lowercase(Locale.US)
val target: Pair<String, String>? = when {
m.contains("xiaomi") || m.contains("redmi") || m.contains("poco") ->
"com.miui.securitycenter" to "com.miui.permcenter.autostart.AutoStartManagementActivity"
m.contains("oppo") || m.contains("realme") ->
"com.coloros.safecenter" to "com.coloros.safecenter.permission.startup.StartupAppListActivity"
m.contains("vivo") ->
"com.vivo.permissionmanager" to "com.vivo.permissionmanager.activity.BgStartUpManagerActivity"
m.contains("huawei") || m.contains("honor") ->
"com.huawei.systemmanager" to "com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity"
else -> null
}
return target?.let { (pkg, cls) ->
Intent().apply { setClassName(pkg, cls) }
}
}
/** 제조사별 수동 안내 문구 키 — 화면에서 문자열 리소스로 매핑한다. */
fun oemGuideKey(): String {
val m = Build.MANUFACTURER.lowercase(Locale.US)
return when {
m.contains("xiaomi") || m.contains("redmi") || m.contains("poco") -> "xiaomi"
m.contains("samsung") -> "samsung"
m.contains("oppo") || m.contains("realme") -> "oppo"
m.contains("vivo") -> "vivo"
else -> "generic"
}
}
/** 진단용 — BLE 로그 헤더에 남겨서 실패 분석 때 기기 상태를 알 수 있게 한다. */
fun diagnosticLine(context: Context): String {
val r = checkRestrictions(context)
return "DEVICE ${Build.MANUFACTURER} ${Build.MODEL} sdk=${Build.VERSION.SDK_INT} " +
"restrictions=${if (r.isEmpty()) "none" else r.joinToString(",") { it.name }}"
}
}