f025bbc0a3
같은 빌드가 태블릿 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>
422 lines
16 KiB
Kotlin
422 lines
16 KiB
Kotlin
package com.medithings.vesiscan.services
|
|
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import androidx.core.content.FileProvider
|
|
import org.json.JSONArray
|
|
import org.json.JSONObject
|
|
import java.io.File
|
|
import java.text.SimpleDateFormat
|
|
import java.util.*
|
|
import java.util.concurrent.CopyOnWriteArrayList
|
|
|
|
/**
|
|
* BLE 디버그 로거 — 펌웨어 디버깅용 상세 통신 로그.
|
|
*
|
|
* 모든 TX/RX 패킷, RSSI, 연결 상태, 에러를 기록하고
|
|
* 파일로 내보내기 가능.
|
|
*
|
|
* 파일 위치: vesiscan_ble_logs/YYYY-MM-DD_HHmmss.log
|
|
*/
|
|
class BleDebugLogger private constructor(private val context: Context) {
|
|
|
|
companion object {
|
|
@Volatile
|
|
private var instance: BleDebugLogger? = null
|
|
fun getInstance(context: Context) = instance ?: synchronized(this) {
|
|
instance ?: BleDebugLogger(context.applicationContext).also { instance = it }
|
|
}
|
|
|
|
private const val MAX_MEMORY_ENTRIES = 2000
|
|
}
|
|
|
|
enum class Level { TX, RX, INFO, WARN, ERROR, RSSI, CONN }
|
|
|
|
data class Entry(
|
|
val timestamp: Long,
|
|
val level: Level,
|
|
val message: String,
|
|
val dataSize: Int = 0,
|
|
val rssi: Int? = null
|
|
) {
|
|
private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
|
val timeStr: String get() = fmt.format(Date(timestamp))
|
|
|
|
fun toLogLine(): String {
|
|
val tag = level.name.padEnd(5)
|
|
val extra = when {
|
|
dataSize > 0 -> " [${dataSize}B]"
|
|
rssi != null -> " [${rssi}dBm]"
|
|
else -> ""
|
|
}
|
|
return "$timeStr $tag $message$extra"
|
|
}
|
|
|
|
fun toJson(): JSONObject = JSONObject().apply {
|
|
put("t", timestamp)
|
|
put("level", level.name)
|
|
put("msg", message)
|
|
if (dataSize > 0) put("bytes", dataSize)
|
|
if (rssi != null) put("rssi", rssi)
|
|
}
|
|
}
|
|
|
|
// In-memory ring buffer
|
|
private val entries = CopyOnWriteArrayList<Entry>()
|
|
|
|
// Session tracking
|
|
private var sessionStart: Long = System.currentTimeMillis()
|
|
var deviceName: String = ""
|
|
private set
|
|
private var txCount = 0
|
|
private var rxCount = 0
|
|
private var errorCount = 0
|
|
private var lastRssi: Int = 0
|
|
private val rssiHistory = mutableListOf<Pair<Long, Int>>()
|
|
private var packetTimeouts = 0
|
|
private var channelMissCount = 0
|
|
|
|
// File
|
|
private val logDir = File(context.filesDir, "vesiscan_ble_logs").also { it.mkdirs() }
|
|
private val dateFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
|
private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
|
|
|
// ── 실시간 파일 기록 ────────────────────────────────────────────────────
|
|
//
|
|
// 2026-08-24 개편. 이전에는 세 가지 이유로 로그가 통으로 안 남았다:
|
|
// 1) connected() 가 파일 핸들을 버려서 **BLE 재연결마다 새 파일**이 생겼다.
|
|
// 앱 시작~첫 연결 구간도 또 다른 파일에 들어갔다.
|
|
// 2) appendToFile 에 동기화가 없었다. rx() 는 BLE 콜백 스레드에서, 나머지는
|
|
// UI 스레드에서 불리므로 동시에 appendText 하면 줄이 섞이거나 유실된다.
|
|
// 3) Downloads 쓰기가 실패해도 예외를 삼켜서 **아무 데도 안 남았다**.
|
|
//
|
|
// 이제 **프로세스 1회당 파일 1개**다. 앱이 켜져서 죽을 때까지 한 파일에 이어 쓴다.
|
|
// 줄마다 flush 하므로 앱이 강제 종료돼도 그 시점까지는 디스크에 남는다.
|
|
|
|
/** 이 프로세스가 시작된 시각 — 세션 파일 이름이자 로그의 기준점. */
|
|
private val processStart: Long = System.currentTimeMillis()
|
|
|
|
private val fileLock = Any()
|
|
private var sessionWriter: java.io.Writer? = null
|
|
private var sessionWriterFailed = false
|
|
|
|
/** 이 프로세스의 통합 로그 파일. Downloads 가 막히면 앱 내부 저장소로 떨어진다. */
|
|
var sessionFile: File? = null
|
|
private set
|
|
|
|
private fun sessionWriter(): java.io.Writer? {
|
|
if (sessionWriter != null || sessionWriterFailed) return sessionWriter
|
|
val name = "VesiScan_BLE_${dateFmt.format(Date(processStart))}.log"
|
|
// Downloads 우선 (파일 탐색기에서 바로 접근). 실패하면 내부 저장소로 폴백해서
|
|
// **로그가 사라지는 일이 없게** 한다.
|
|
val candidates = listOf(
|
|
android.os.Environment.getExternalStoragePublicDirectory(
|
|
android.os.Environment.DIRECTORY_DOWNLOADS,
|
|
),
|
|
logDir,
|
|
)
|
|
for (dir in candidates) {
|
|
try {
|
|
dir?.mkdirs()
|
|
val f = File(dir, name)
|
|
val w = java.io.BufferedWriter(java.io.FileWriter(f, true))
|
|
sessionFile = f
|
|
sessionWriter = w
|
|
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
|
|
} catch (_: Exception) { /* 다음 후보로 */ }
|
|
}
|
|
sessionWriterFailed = true // 매 줄마다 재시도하지 않는다
|
|
return null
|
|
}
|
|
|
|
// 임상 모드는 기존대로 step 폴더의 ble.log 에도 **같이** 남긴다 (step 전환 시 파일 교체).
|
|
// 통합 세션 파일에는 어차피 전부 들어가므로 이쪽은 부가 사본이다.
|
|
private var clinicalWriter: java.io.Writer? = null
|
|
private var clinicalLabel: String = ""
|
|
|
|
private fun clinicalWriter(): java.io.Writer? {
|
|
val dir = ClinicalSessionStore.currentLogDir()
|
|
if (dir == null) {
|
|
clinicalWriter?.runCatching { close() }
|
|
clinicalWriter = null
|
|
clinicalLabel = ""
|
|
return null
|
|
}
|
|
val label = ClinicalSessionStore.currentLabel()
|
|
if (clinicalWriter == null || clinicalLabel != label) {
|
|
clinicalWriter?.runCatching { close() }
|
|
clinicalWriter = try {
|
|
dir.mkdirs()
|
|
java.io.BufferedWriter(java.io.FileWriter(File(dir, "ble.log"), true))
|
|
} catch (_: Exception) { null }
|
|
clinicalLabel = label
|
|
}
|
|
return clinicalWriter
|
|
}
|
|
|
|
private fun appendToFile(line: String) {
|
|
synchronized(fileLock) {
|
|
try {
|
|
sessionWriter()?.apply { write(line); write("\n"); flush() }
|
|
} catch (_: Exception) {}
|
|
try {
|
|
clinicalWriter()?.apply { write(line); write("\n"); flush() }
|
|
} catch (_: Exception) {}
|
|
}
|
|
}
|
|
|
|
/** 앱 종료 직전에 불러 파일을 정리한다. 호출을 놓쳐도 줄마다 flush 하므로 손실은 없다. */
|
|
fun close() {
|
|
synchronized(fileLock) {
|
|
try { sessionWriter?.apply { write("=== LOG CLOSED ===\n"); flush(); close() } } catch (_: Exception) {}
|
|
try { clinicalWriter?.apply { flush(); close() } } catch (_: Exception) {}
|
|
sessionWriter = null
|
|
clinicalWriter = null
|
|
}
|
|
}
|
|
|
|
// ── Logging Methods ──
|
|
|
|
fun tx(command: String, dataSize: Int = 0) {
|
|
txCount++
|
|
add(Entry(System.currentTimeMillis(), Level.TX, command, dataSize))
|
|
}
|
|
|
|
fun rx(prefix: String, dataSize: Int = 0, detail: String = "") {
|
|
rxCount++
|
|
val msg = if (detail.isNotEmpty()) "$prefix $detail" else prefix
|
|
add(Entry(System.currentTimeMillis(), Level.RX, msg, dataSize))
|
|
}
|
|
|
|
fun rssi(value: Int) {
|
|
lastRssi = value
|
|
rssiHistory.add(System.currentTimeMillis() to value)
|
|
if (rssiHistory.size > 500) rssiHistory.removeAt(0)
|
|
add(Entry(System.currentTimeMillis(), Level.RSSI, "RSSI=$value dBm", rssi = value))
|
|
}
|
|
|
|
/** mid?/rid: 응답 받은 후 호출 — 펌웨어 정보를 ble.log에 보기 좋게 헤더 형태로 기록 */
|
|
fun deviceInfo(fw: String, hw: String, sn: String) {
|
|
appendToFile("─── Device Info ───")
|
|
if (fw.isNotEmpty()) appendToFile("Firmware: $fw")
|
|
if (hw.isNotEmpty()) appendToFile("Hardware: $hw")
|
|
if (sn.isNotEmpty()) appendToFile("Serial: $sn")
|
|
appendToFile("───────────────────")
|
|
}
|
|
|
|
fun connected(name: String, address: String) {
|
|
deviceName = name
|
|
sessionStart = System.currentTimeMillis()
|
|
txCount = 0; rxCount = 0; errorCount = 0; packetTimeouts = 0; channelMissCount = 0
|
|
rssiHistory.clear()
|
|
// ⚠️ 여기서 파일을 새로 만들지 않는다. 재연결마다 파일이 갈라지던 원인이었다.
|
|
// 통계(tx/rx/err)만 리셋하고 파일에는 구분선을 남긴다.
|
|
appendToFile("")
|
|
appendToFile("─── CONNECTED $name ($address) ${dateFmt.format(Date())} ───")
|
|
add(Entry(System.currentTimeMillis(), Level.CONN, "CONNECTED $name ($address)"))
|
|
}
|
|
|
|
fun disconnected(reason: String = "", gattStatus: Int = -1) {
|
|
val msg = buildString {
|
|
append("DISCONNECTED")
|
|
if (reason.isNotEmpty()) append(" reason=$reason")
|
|
if (gattStatus >= 0) append(" gatt_status=$gattStatus")
|
|
append(" session=${sessionDurationSec()}s tx=$txCount rx=$rxCount err=$errorCount")
|
|
}
|
|
add(Entry(System.currentTimeMillis(), Level.CONN, msg))
|
|
}
|
|
|
|
fun reconnecting(attempt: Int, maxAttempt: Int, delayMs: Long) {
|
|
add(Entry(System.currentTimeMillis(), Level.CONN, "RECONNECTING attempt=$attempt/$maxAttempt delay=${delayMs}ms"))
|
|
}
|
|
|
|
fun reconnectFailed() {
|
|
errorCount++
|
|
add(Entry(System.currentTimeMillis(), Level.ERROR, "RECONNECT_FAILED after all attempts"))
|
|
}
|
|
|
|
fun info(message: String) {
|
|
add(Entry(System.currentTimeMillis(), Level.INFO, message))
|
|
}
|
|
|
|
fun warn(message: String) {
|
|
add(Entry(System.currentTimeMillis(), Level.WARN, message))
|
|
}
|
|
|
|
fun error(message: String) {
|
|
errorCount++
|
|
add(Entry(System.currentTimeMillis(), Level.ERROR, message))
|
|
}
|
|
|
|
fun packetTimeout(channel: Int, retryCount: Int) {
|
|
packetTimeouts++
|
|
error("PACKET_TIMEOUT ch=$channel retry=$retryCount")
|
|
}
|
|
|
|
fun channelMissing(expectedChannels: Int, receivedChannels: Int) {
|
|
channelMissCount++
|
|
warn("CHANNEL_MISS expected=$expectedChannels received=$receivedChannels")
|
|
}
|
|
|
|
fun endianDetected(isBigEndian: Boolean, firstSampleLE: Int, firstSampleBE: Int) {
|
|
info("ENDIAN_DETECT ${if (isBigEndian) "BIG" else "LITTLE"} (LE=$firstSampleLE, BE=$firstSampleBE)")
|
|
}
|
|
|
|
fun measurementResult(volumeMl: Double?, lrRatio: Double, validChannels: Int, urineLines: List<Int>, otsuThreshold: Double = 0.0, scanId: Int? = null) {
|
|
// 2026-08-04: LOG_STORAGE_ANALYSIS §A fix — 호출자가 명시적 scanId 전달 시 사용.
|
|
// 미제공 시 legacy fallback (AdcCsvLogger.lastScanId · off-by-one 이었던 원인).
|
|
val id = scanId ?: AdcCsvLogger.lastScanId
|
|
val msg = buildString {
|
|
append("MEASUREMENT scan=$id")
|
|
append(" vol=${volumeMl?.let { "%.1f".format(it) } ?: "null"}ml")
|
|
append(" lr=${"%.2f".format(lrRatio)}")
|
|
append(" valid=$validChannels/6")
|
|
append(" urine=[${urineLines.joinToString(",")}]")
|
|
if (otsuThreshold > 0) append(" otsu=${"%.0f".format(otsuThreshold)}")
|
|
}
|
|
add(Entry(System.currentTimeMillis(), Level.INFO, msg))
|
|
}
|
|
|
|
// ── Access ──
|
|
|
|
private fun add(entry: Entry) {
|
|
entries.add(entry)
|
|
if (entries.size > MAX_MEMORY_ENTRIES) {
|
|
entries.removeAt(0)
|
|
}
|
|
appendToFile(entry.toLogLine())
|
|
}
|
|
|
|
fun getEntries(): List<Entry> = entries.toList()
|
|
|
|
fun getRecentEntries(count: Int = 100): List<Entry> {
|
|
val all = entries.toList()
|
|
return if (all.size > count) all.subList(all.size - count, all.size) else all
|
|
}
|
|
|
|
fun clear() {
|
|
entries.clear()
|
|
}
|
|
|
|
// ── Stats ──
|
|
|
|
private fun sessionDurationSec(): Long = (System.currentTimeMillis() - sessionStart) / 1000
|
|
|
|
fun getStats(): JSONObject = JSONObject().apply {
|
|
put("session_duration_sec", sessionDurationSec())
|
|
put("device", deviceName)
|
|
put("tx_count", txCount)
|
|
put("rx_count", rxCount)
|
|
put("error_count", errorCount)
|
|
put("packet_timeouts", packetTimeouts)
|
|
put("channel_misses", channelMissCount)
|
|
put("last_rssi", lastRssi)
|
|
if (rssiHistory.isNotEmpty()) {
|
|
put("avg_rssi", rssiHistory.map { it.second }.average().toInt())
|
|
put("min_rssi", rssiHistory.minOf { it.second })
|
|
put("max_rssi", rssiHistory.maxOf { it.second })
|
|
}
|
|
}
|
|
|
|
// ── File Export ──
|
|
|
|
private fun writeLogContent(writer: java.io.BufferedWriter) {
|
|
writer.write("=== VesiScan BLE Debug Log ===\n")
|
|
writer.write("Device: $deviceName\n")
|
|
writer.write("Session: ${dateFmt.format(Date(sessionStart))}\n")
|
|
writer.write("Duration: ${sessionDurationSec()}s\n")
|
|
writer.write("TX: $txCount RX: $rxCount Errors: $errorCount Timeouts: $packetTimeouts\n")
|
|
|
|
if (rssiHistory.isNotEmpty()) {
|
|
writer.write("RSSI: avg=${rssiHistory.map { it.second }.average().toInt()} ")
|
|
writer.write("min=${rssiHistory.minOf { it.second }} max=${rssiHistory.maxOf { it.second }}\n")
|
|
}
|
|
|
|
writer.write("=".repeat(60) + "\n\n")
|
|
|
|
for (entry in entries) {
|
|
writer.write(entry.toLogLine() + "\n")
|
|
}
|
|
}
|
|
|
|
fun exportToFile(): File {
|
|
val fileName = "ble_debug_${dateFmt.format(Date())}.log"
|
|
val file = File(logDir, fileName)
|
|
file.bufferedWriter().use { writeLogContent(it) }
|
|
return file
|
|
}
|
|
|
|
/**
|
|
* 내보내기 — **이미 실시간으로 쌓고 있는 세션 파일을 그대로 준다.**
|
|
*
|
|
* 예전에는 메모리 링버퍼([MAX_MEMORY_ENTRIES] = 2000줄)를 새 파일로 덤프해서
|
|
* "마지막 몇 분"만 나왔다. 세션 파일에는 프로세스 시작부터 전부 들어 있다.
|
|
*/
|
|
fun exportToDownloads(): File? {
|
|
synchronized(fileLock) {
|
|
try { sessionWriter()?.flush() } catch (_: Exception) {}
|
|
}
|
|
return sessionFile?.takeIf { it.exists() }
|
|
}
|
|
|
|
fun exportToJsonFile(): File {
|
|
val fileName = "ble_debug_${dateFmt.format(Date())}.jsonl"
|
|
val file = File(logDir, fileName)
|
|
|
|
file.bufferedWriter().use { writer ->
|
|
val header = JSONObject().apply {
|
|
put("type", "header")
|
|
put("device", deviceName)
|
|
put("session_start", sessionStart)
|
|
put("stats", getStats())
|
|
}
|
|
writer.write(header.toString() + "\n")
|
|
|
|
for (entry in entries) {
|
|
writer.write(entry.toJson().toString() + "\n")
|
|
}
|
|
}
|
|
|
|
return file
|
|
}
|
|
|
|
fun getShareIntent(): Intent {
|
|
val file = exportToFile()
|
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
|
return Intent(Intent.ACTION_SEND).apply {
|
|
type = "text/plain"
|
|
putExtra(Intent.EXTRA_STREAM, uri)
|
|
putExtra(Intent.EXTRA_SUBJECT, "VesiScan BLE Debug Log — $deviceName")
|
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
}
|
|
}
|
|
|
|
fun getLogText(): String {
|
|
val sb = StringBuilder()
|
|
sb.appendLine("=== VesiScan BLE Debug ===")
|
|
sb.appendLine("Device: $deviceName | TX:$txCount RX:$rxCount Err:$errorCount Timeout:$packetTimeouts")
|
|
if (rssiHistory.isNotEmpty()) {
|
|
sb.appendLine("RSSI: avg=${rssiHistory.map { it.second }.average().toInt()} min=${rssiHistory.minOf { it.second }}")
|
|
}
|
|
sb.appendLine("---")
|
|
for (entry in entries.takeLast(200)) {
|
|
sb.appendLine(entry.toLogLine())
|
|
}
|
|
return sb.toString()
|
|
}
|
|
|
|
/** 7일 이상 된 로그 삭제 */
|
|
fun purgeOldLogs(keepDays: Int = 7) {
|
|
val cutoff = System.currentTimeMillis() - keepDays * 24 * 60 * 60 * 1000L
|
|
logDir.listFiles()?.filter { it.lastModified() < cutoff }?.forEach { it.delete() }
|
|
}
|
|
}
|