From f025bbc0a3f41c41a1273c3caf4d7dc245ede5aa Mon Sep 17 00:00:00 2001 From: jjangddu Date: Mon, 24 Aug 2026 11:13:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(longrun):=20=EC=9E=A5=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=95=20=EC=A4=91=EB=8B=A8=20=EA=B0=90=EC=A7=80=20?= =?UTF-8?q?+=20=EC=84=A4=EC=A0=95=EC=97=90=20=EC=A4=80=EB=B9=84=20?= =?UTF-8?q?=EC=A0=90=EA=B2=80=20=ED=95=AD=EB=AA=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 빌드가 태블릿 34시간 / Xiaomi 40분 이었던 문제 대응. 앱 구조(Foreground Service + connectedDevice + WakeLock)는 이미 맞았고 제조사 배터리 관리자가 프로세스를 죽인 것이라, 구조 변경이 아니라 감지와 안내로 접근한다. - LongRunGuard: 측정 시작/생존/정상종료를 기록해 비정상 종료를 감지. 다음 실행 때 "언제부터 언제까지 몇 분" 을 알려준다. 제조사 무관하게 동작. - BLE 로그 헤더에 기기/제한 상태 진단줄 (실패 후 원인 판별용). - 설정 패널에 "장시간 측정 준비" — 배터리 최적화·미사용앱 제한 상태 확인 및 해제 진입. 자동시작은 상태를 읽을 수 없어 안내만 제공. 측정 시작을 모달로 막지 않는다. 초안에서는 Auto Scan 에 사전점검을 걸었는데 OEM_AUTOSTART 를 제조사 이름만으로 판단해서(상태 조회 API 가 없음) 설정을 완벽히 해둔 기기에도 영원히 뜨는 문제가 있었다. 고칠 수도 없는 항목으로 측정을 가로막으면 경고가 무시된다. 추측으로 경고하지 않고, 실제로 중단됐을 때만 알린다. Co-Authored-By: Claude Opus 5 (1M context) --- .../vesiscan/services/BleDebugLogger.kt | 3 + .../vesiscan/services/LongRunGuard.kt | 225 +++++++++++++++++ .../ui/components/LongRunPreflightDialog.kt | 235 ++++++++++++++++++ .../views/monitoring/PiezoMonitoringView.kt | 78 ++++++ app/src/main/res/values-ko/strings.xml | 23 ++ app/src/main/res/values/strings.xml | 23 ++ 6 files changed, 587 insertions(+) create mode 100644 app/src/main/java/com/medithings/vesiscan/services/LongRunGuard.kt create mode 100644 app/src/main/java/com/medithings/vesiscan/ui/components/LongRunPreflightDialog.kt diff --git a/app/src/main/java/com/medithings/vesiscan/services/BleDebugLogger.kt b/app/src/main/java/com/medithings/vesiscan/services/BleDebugLogger.kt index bbe5a31..92ae708 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/BleDebugLogger.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/BleDebugLogger.kt @@ -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 diff --git a/app/src/main/java/com/medithings/vesiscan/services/LongRunGuard.kt b/app/src/main/java/com/medithings/vesiscan/services/LongRunGuard.kt new file mode 100644 index 0000000..b9b43b3 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/services/LongRunGuard.kt @@ -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 { + val out = mutableListOf() + + 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? = 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 }}" + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/components/LongRunPreflightDialog.kt b/app/src/main/java/com/medithings/vesiscan/ui/components/LongRunPreflightDialog.kt new file mode 100644 index 0000000..cd34b4f --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/ui/components/LongRunPreflightDialog.kt @@ -0,0 +1,235 @@ +package com.medithings.vesiscan.ui.components + +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.medithings.vesiscan.R +import com.medithings.vesiscan.services.LongRunGuard + +/** + * 장시간 측정 준비 — **설정에서 사용자가 직접 열 때만** 뜬다. + * + * ## 왜 측정 시작을 막지 않나 + * 처음에는 Auto Scan 을 누를 때 사전점검 모달을 띄웠는데 두 가지가 잘못됐다. + * - 제조사 자동시작은 **상태를 읽을 수 없어** "샤오미면 무조건 경고"가 됐다. + * 설정을 완벽히 해둔 사용자에게도 영원히 뜬다. + * - 고칠 수도 없는 항목으로 측정 시작을 가로막는 건 방해일 뿐이다. + * + * 그래서 지금은 (1) 여기서 사용자가 원할 때 점검하고, + * (2) **실제로 측정이 중단됐을 때만** [MeasurementInterruptedDialog] 로 알린다. + * 추측으로 경고하지 않고, 증거가 생기면 그때 말한다. + */ +@Composable +fun LongRunPrepDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + // 설정 화면에 다녀오면 해결된 항목이 목록에서 빠지도록 상태로 들고 간다. + var blockers by remember { mutableStateOf(LongRunGuard.checkRestrictions(context)) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringResource(R.string.longrun_preflight_title), + fontWeight = FontWeight.Bold, + ) + }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text(stringResource(R.string.longrun_preflight_desc), fontSize = 13.sp) + + if (blockers.isEmpty()) { + Text( + stringResource(R.string.longrun_all_clear), + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF2E7D32), + ) + } + + blockers.forEach { r -> + RestrictionRow(context, r) { + blockers = LongRunGuard.checkRestrictions(context) + } + } + + // 자동시작은 상태를 못 읽고 해제도 못 한다 — 항상 안내로만 보여준다. + // 공격적인 배터리 관리로 알려진 제조사에서만 노출한다. + if (LongRunGuard.isAggressiveOem()) { + AutostartGuide(context) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.longrun_interrupted_dismiss)) + } + }, + ) +} + +/** 제조사 자동시작 — 앱이 확인·해제할 수 없어 안내 + 설정 진입만 제공한다. */ +@Composable +private fun AutostartGuide(context: Context) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.longrun_autostart_title), + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(R.string.longrun_autostart_desc), + fontSize = 11.sp, + color = Color(0xFF777777), + ) + } + TextButton(onClick = { + launchSettings(context, LongRunGuard.Restriction.OEM_AUTOSTART) + }) { + Text(stringResource(R.string.longrun_open_settings), fontSize = 12.sp) + } + } + Text( + text = stringResource( + when (LongRunGuard.oemGuideKey()) { + "xiaomi" -> R.string.longrun_guide_xiaomi + "samsung" -> R.string.longrun_guide_samsung + "oppo" -> R.string.longrun_guide_oppo + "vivo" -> R.string.longrun_guide_vivo + else -> R.string.longrun_guide_generic + }, + ), + fontSize = 12.sp, + lineHeight = 18.sp, + color = Color(0xFF555555), + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF3F3F3), RoundedCornerShape(8.dp)) + .padding(10.dp), + ) + } +} + +@Composable +private fun RestrictionRow( + context: Context, + r: LongRunGuard.Restriction, + onReturned: () -> Unit, +) { + val (titleRes, descRes) = when (r) { + LongRunGuard.Restriction.BATTERY_OPTIMIZATION -> + R.string.longrun_battery_title to R.string.longrun_battery_desc + LongRunGuard.Restriction.APP_HIBERNATION -> + R.string.longrun_hibernation_title to R.string.longrun_hibernation_desc + LongRunGuard.Restriction.OEM_AUTOSTART -> + R.string.longrun_autostart_title to R.string.longrun_autostart_desc + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(stringResource(titleRes), fontSize = 13.sp, fontWeight = FontWeight.Bold) + Text(stringResource(descRes), fontSize = 11.sp, color = Color(0xFF777777)) + } + TextButton(onClick = { + launchSettings(context, r) + onReturned() + }) { + Text(stringResource(R.string.longrun_fix), fontSize = 12.sp) + } + } +} + +/** + * 설정 화면 진입. 제조사 비공개 화면은 기종에 따라 없을 수 있어 + * 실패하면 **앱 상세정보 화면으로 폴백**한다 — 아무 일도 안 일어나는 게 제일 나쁘다. + */ +private fun launchSettings(context: Context, r: LongRunGuard.Restriction) { + val tryStart = { i: Intent -> + runCatching { + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(i) + }.isSuccess + } + val intent = LongRunGuard.resolveIntentFor(context, r) + if (intent != null && tryStart(intent)) return + if (tryStart(LongRunGuard.appDetailsIntent(context))) return + Toast.makeText(context, R.string.longrun_guide_generic, Toast.LENGTH_LONG).show() +} + +/** + * 지난 실행에서 측정이 시스템에 의해 끊겼음을 알린다. + * 추측이 아니라 **실제로 일어난 일**이라 알릴 가치가 있다. + */ +@Composable +fun MeasurementInterruptedDialog( + interruption: LongRunGuard.Interruption, + onDismiss: () -> Unit, + onOpenSettings: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringResource(R.string.longrun_interrupted_title), + fontWeight = FontWeight.Bold, + ) + }, + text = { + Text( + stringResource( + R.string.longrun_interrupted_body, + interruption.startText, + interruption.endText, + interruption.durationMin, + ), + fontSize = 13.sp, + lineHeight = 19.sp, + ) + }, + confirmButton = { + TextButton(onClick = onOpenSettings) { + Text(stringResource(R.string.longrun_interrupted_fix)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.longrun_interrupted_dismiss)) + } + }, + ) +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt index 5770e68..e16949b 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll 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.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -685,10 +686,23 @@ fun PiezoMonitoringView(appState: AppState) { var autoTickCount by remember { mutableIntStateOf(0) } + // 장시간 측정 준비 화면 — 설정 패널에서 사용자가 직접 열 때만 뜬다. + // (측정 시작을 막지 않는다. 추측만으로 경고하면 늑대소년이 된다.) + var showLongRunPrep by remember { mutableStateOf(false) } + // 지난 실행이 시스템에 의해 종료됐는지 (앱 진입 시 1회 확인). + var interruption by remember { + mutableStateOf(com.medithings.vesiscan.services.LongRunGuard.consumeInterruption(context)) + } + fun startAutoMeasure() { isAutoMeasuring = true autoTickCount = 0 autoVolumeWindow.clear() + // 장시간 측정 보호: 시작을 기록해 둔다. 앱이 시스템에 의해 죽으면 이 기록이 + // 남아서 다음 실행 때 "중단되었습니다" 안내가 뜬다 (LongRunGuard). + com.medithings.vesiscan.services.LongRunGuard.markMeasurementStart( + context, bleManager.connectedDeviceName.value, + ) measure() } @@ -698,6 +712,26 @@ fun PiezoMonitoringView(appState: AppState) { // Stop 시 현재 표시값 유지 (재계산 안 함) Log.d("PiezoMonitor", "AUTO STOP — keeping displayed vol=${"%.1f".format(lastVolumeMl)}ml") autoVolumeWindow.clear() + // 사용자가 멈춘 정상 종료 → 중단 기록 삭제. + com.medithings.vesiscan.services.LongRunGuard.markCleanStop(context) + } + + if (showLongRunPrep) { + com.medithings.vesiscan.ui.components.LongRunPrepDialog( + onDismiss = { showLongRunPrep = false }, + ) + } + + // 실제로 중단됐을 때만 뜬다 — 추측이 아니라 사실이라 알릴 가치가 있다. + interruption?.let { itp -> + com.medithings.vesiscan.ui.components.MeasurementInterruptedDialog( + interruption = itp, + onDismiss = { interruption = null }, + onOpenSettings = { + interruption = null + showLongRunPrep = true + }, + ) } // Auto-measure: 측정 완료 대기 + 최소 간격 보장 @@ -713,6 +747,8 @@ fun PiezoMonitoringView(appState: AppState) { kotlinx.coroutines.delay(100) } autoTickCount++ + // 살아있음 갱신 — 이 값이 있어야 "언제까지 돌다 끊겼는지"를 알 수 있다. + com.medithings.vesiscan.services.LongRunGuard.heartbeat(context) val elapsed = System.currentTimeMillis() - measureStartMs val targetIntervalMs = appState.piezoSettings.autoScanIntervalMs.toLong() val remaining = targetIntervalMs - elapsed @@ -2520,6 +2556,48 @@ fun PiezoMonitoringView(appState: AppState) { HorizontalDivider() Spacer(modifier = Modifier.height(8.dp)) + // 장시간 측정 준비 — 배터리/백그라운드 제한 점검. + // 측정 시작을 막지 않고 여기서 원할 때만 연다 (LongRunPrepDialog 주석 참고). + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + showSettings = false + showLongRunPrep = true + } + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.BatteryAlert, + contentDescription = null, + tint = MlPrimary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.longrun_preflight_title), + fontSize = labelSize, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(R.string.longrun_preflight_desc), + fontSize = (11 * fontScale).sp, + color = MlSecondaryText, + ) + } + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MlSecondaryText, + modifier = Modifier.size(20.dp), + ) + } + + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + // Save / Cancel — Save가 piezoSettings을 영구 저장 + state 전파 (다음 측정에 반영) Row( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 27ec893..1b3cc01 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -379,4 +379,27 @@ 좋음 보통 약함 + 장시간 측정 준비 + 장시간 측정 중 시스템이 앱을 멈추지 않도록 아래를 확인해 주세요. + 배터리 최적화 + 화면을 꺼도 앱이 계속 동작하도록 합니다. + 사용하지 않는 경우 앱 활동 일시정지 + 이 항목을 꺼야 앱이 중단되지 않습니다. + 백그라운드 자동시작 + 앱이 대신 켤 수 없어 직접 켜주셔야 합니다. + 해제하기 + 설정 열기 + 그래도 시작 + 설정에 문제가 없습니다. + 설정 → 배터리 → 앱 배터리 절약 → VesiScan → 제한 없음 +설정 → 앱 → VesiScan → 자동시작 → 켜기 + 설정 → 디바이스 케어 → 배터리 → 백그라운드 앱 사용 제한 → 자동 절전 예외 앱 → VesiScan 추가 + 설정 → 배터리 → 앱 배터리 관리 → VesiScan → 백그라운드 실행 허용 + 설정 → 배터리 → 백그라운드 전력 소모 관리 → VesiScan → 허용 + 휴대폰의 절전 모드를 끄고 앱의 백그라운드 실행을 허용해 주세요. + 이전 측정이 중단되었습니다 + %1$s ~ %2$s (약 %3$d분) +시스템이 앱을 종료한 것으로 보입니다. 배터리 설정을 확인하면 다음 측정에서 줄일 수 있습니다. + 닫기 + 준비 확인 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af8704d..017a4a0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -379,4 +379,27 @@ Good Fair Weak + Long measurement setup + Check the items below so the system does not stop the app during long measurements. + Battery optimization + Lets the app keep running while the screen is off. + Pause app activity if unused + Turn this off so the app is not suspended. + Background autostart + Must be enabled manually — it cannot be changed by the app. + Fix + Open settings + Start anyway + All settings look fine. + Settings → Battery → App battery saver → VesiScan → No restrictions +Settings → Apps → VesiScan → Autostart → On + Settings → Battery → Background usage limits → Never sleeping apps → add VesiScan. + Settings → Battery → App battery management → VesiScan → Allow background running. + Settings → Battery → Background power consumption management → VesiScan → Allow. + Please turn off battery saving and allow background execution for this app. + Previous measurement was interrupted + %1$s – %2$s (about %3$d min) +The system appears to have stopped the app. Checking battery settings can reduce this next time. + Close + Check setup