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("=== VesiScan BLE Debug Log ===\n")
w.write("Process start: ${dateFmt.format(Date(processStart))}\n") w.write("Process start: ${dateFmt.format(Date(processStart))}\n")
w.write("File: ${f.absolutePath}\n") w.write("File: ${f.absolutePath}\n")
// 기기/제조사 제한 상태 — 장시간 측정이 중간에 죽었을 때 원인 판별용.
// (같은 빌드가 태블릿 34시간 / Xiaomi 40분 이었던 사례)
w.write(LongRunGuard.diagnosticLine(context) + "\n")
w.write("=".repeat(60) + "\n") w.write("=".repeat(60) + "\n")
w.flush() w.flush()
return w 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 }}"
}
}
@@ -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))
}
},
)
}
@@ -21,6 +21,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -685,10 +686,23 @@ fun PiezoMonitoringView(appState: AppState) {
var autoTickCount by remember { mutableIntStateOf(0) } 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() { fun startAutoMeasure() {
isAutoMeasuring = true isAutoMeasuring = true
autoTickCount = 0 autoTickCount = 0
autoVolumeWindow.clear() autoVolumeWindow.clear()
// 장시간 측정 보호: 시작을 기록해 둔다. 앱이 시스템에 의해 죽으면 이 기록이
// 남아서 다음 실행 때 "중단되었습니다" 안내가 뜬다 (LongRunGuard).
com.medithings.vesiscan.services.LongRunGuard.markMeasurementStart(
context, bleManager.connectedDeviceName.value,
)
measure() measure()
} }
@@ -698,6 +712,26 @@ fun PiezoMonitoringView(appState: AppState) {
// Stop 시 현재 표시값 유지 (재계산 안 함) // Stop 시 현재 표시값 유지 (재계산 안 함)
Log.d("PiezoMonitor", "AUTO STOP — keeping displayed vol=${"%.1f".format(lastVolumeMl)}ml") Log.d("PiezoMonitor", "AUTO STOP — keeping displayed vol=${"%.1f".format(lastVolumeMl)}ml")
autoVolumeWindow.clear() 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: 측정 완료 대기 + 최소 간격 보장 // Auto-measure: 측정 완료 대기 + 최소 간격 보장
@@ -713,6 +747,8 @@ fun PiezoMonitoringView(appState: AppState) {
kotlinx.coroutines.delay(100) kotlinx.coroutines.delay(100)
} }
autoTickCount++ autoTickCount++
// 살아있음 갱신 — 이 값이 있어야 "언제까지 돌다 끊겼는지"를 알 수 있다.
com.medithings.vesiscan.services.LongRunGuard.heartbeat(context)
val elapsed = System.currentTimeMillis() - measureStartMs val elapsed = System.currentTimeMillis() - measureStartMs
val targetIntervalMs = appState.piezoSettings.autoScanIntervalMs.toLong() val targetIntervalMs = appState.piezoSettings.autoScanIntervalMs.toLong()
val remaining = targetIntervalMs - elapsed val remaining = targetIntervalMs - elapsed
@@ -2520,6 +2556,48 @@ fun PiezoMonitoringView(appState: AppState) {
HorizontalDivider() HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp)) 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 전파 (다음 측정에 반영) // Save / Cancel — Save가 piezoSettings을 영구 저장 + state 전파 (다음 측정에 반영)
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
+23
View File
@@ -379,4 +379,27 @@
<string name="device_scan_signal_good">좋음</string> <string name="device_scan_signal_good">좋음</string>
<string name="device_scan_signal_fair">보통</string> <string name="device_scan_signal_fair">보통</string>
<string name="device_scan_signal_weak">약함</string> <string name="device_scan_signal_weak">약함</string>
<string name="longrun_preflight_title">장시간 측정 준비</string>
<string name="longrun_preflight_desc">장시간 측정 중 시스템이 앱을 멈추지 않도록 아래를 확인해 주세요.</string>
<string name="longrun_battery_title">배터리 최적화</string>
<string name="longrun_battery_desc">화면을 꺼도 앱이 계속 동작하도록 합니다.</string>
<string name="longrun_hibernation_title">사용하지 않는 경우 앱 활동 일시정지</string>
<string name="longrun_hibernation_desc">이 항목을 꺼야 앱이 중단되지 않습니다.</string>
<string name="longrun_autostart_title">백그라운드 자동시작</string>
<string name="longrun_autostart_desc">앱이 대신 켤 수 없어 직접 켜주셔야 합니다.</string>
<string name="longrun_fix">해제하기</string>
<string name="longrun_open_settings">설정 열기</string>
<string name="longrun_start_anyway">그래도 시작</string>
<string name="longrun_all_clear">설정에 문제가 없습니다.</string>
<string name="longrun_guide_xiaomi">설정 → 배터리 → 앱 배터리 절약 → VesiScan → 제한 없음
설정 → 앱 → VesiScan → 자동시작 → 켜기</string>
<string name="longrun_guide_samsung">설정 → 디바이스 케어 → 배터리 → 백그라운드 앱 사용 제한 → 자동 절전 예외 앱 → VesiScan 추가</string>
<string name="longrun_guide_oppo">설정 → 배터리 → 앱 배터리 관리 → VesiScan → 백그라운드 실행 허용</string>
<string name="longrun_guide_vivo">설정 → 배터리 → 백그라운드 전력 소모 관리 → VesiScan → 허용</string>
<string name="longrun_guide_generic">휴대폰의 절전 모드를 끄고 앱의 백그라운드 실행을 허용해 주세요.</string>
<string name="longrun_interrupted_title">이전 측정이 중단되었습니다</string>
<string name="longrun_interrupted_body">%1$s ~ %2$s (약 %3$d분)
시스템이 앱을 종료한 것으로 보입니다. 배터리 설정을 확인하면 다음 측정에서 줄일 수 있습니다.</string>
<string name="longrun_interrupted_dismiss">닫기</string>
<string name="longrun_interrupted_fix">준비 확인</string>
</resources> </resources>
+23
View File
@@ -379,4 +379,27 @@
<string name="device_scan_signal_good">Good</string> <string name="device_scan_signal_good">Good</string>
<string name="device_scan_signal_fair">Fair</string> <string name="device_scan_signal_fair">Fair</string>
<string name="device_scan_signal_weak">Weak</string> <string name="device_scan_signal_weak">Weak</string>
<string name="longrun_preflight_title">Long measurement setup</string>
<string name="longrun_preflight_desc">Check the items below so the system does not stop the app during long measurements.</string>
<string name="longrun_battery_title">Battery optimization</string>
<string name="longrun_battery_desc">Lets the app keep running while the screen is off.</string>
<string name="longrun_hibernation_title">Pause app activity if unused</string>
<string name="longrun_hibernation_desc">Turn this off so the app is not suspended.</string>
<string name="longrun_autostart_title">Background autostart</string>
<string name="longrun_autostart_desc">Must be enabled manually — it cannot be changed by the app.</string>
<string name="longrun_fix">Fix</string>
<string name="longrun_open_settings">Open settings</string>
<string name="longrun_start_anyway">Start anyway</string>
<string name="longrun_all_clear">All settings look fine.</string>
<string name="longrun_guide_xiaomi">Settings → Battery → App battery saver → VesiScan → No restrictions
Settings → Apps → VesiScan → Autostart → On</string>
<string name="longrun_guide_samsung">Settings → Battery → Background usage limits → Never sleeping apps → add VesiScan.</string>
<string name="longrun_guide_oppo">Settings → Battery → App battery management → VesiScan → Allow background running.</string>
<string name="longrun_guide_vivo">Settings → Battery → Background power consumption management → VesiScan → Allow.</string>
<string name="longrun_guide_generic">Please turn off battery saving and allow background execution for this app.</string>
<string name="longrun_interrupted_title">Previous measurement was interrupted</string>
<string name="longrun_interrupted_body">%1$s – %2$s (about %3$d min)
The system appears to have stopped the app. Checking battery settings can reduce this next time.</string>
<string name="longrun_interrupted_dismiss">Close</string>
<string name="longrun_interrupted_fix">Check setup</string>
</resources> </resources>