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() // 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>() 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, 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 = entries.toList() fun getRecentEntries(count: Int = 100): List { 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() } } }