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:
@@ -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(),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user