fix(ble-log): 앱 시작~종료를 한 파일에 통으로 자동 저장
Downloads 에 VesiScan_BLE_*.log 가 여러 개 생기고 그마저 내용이 비던 문제. 원인이 셋이었다. 1. 재연결마다 파일이 갈라짐 connected() 가 liveLogFile=null 로 핸들을 버려서 BLE 가 끊겼다 붙을 때마다 새 파일이 생겼다. 앱 시작~첫 연결 구간도 별도 파일로 빠졌다. → 프로세스 1회당 파일 1개. 재연결 시에는 구분선만 남긴다. 2. 파일 쓰기에 동기화가 없음 rx() 는 BLE 콜백 스레드, 나머지는 UI 스레드에서 호출되는데 매번 파일을 새로 열어 쓰며 잠금이 없었다. 동시 쓰기로 줄이 섞이거나 유실됐다. → synchronized + 핸들 유지, 줄마다 flush (강제 종료에도 그 시점까지 보존). 3. 저장 실패가 조용히 묻힘 Downloads 쓰기가 막히면 예외를 삼켜 아무 데도 안 남았다. → 내부 저장소로 폴백하고 실제 경로를 파일 헤더에 기록. 추가로 두 가지: - Export 버튼이 메모리 링버퍼(2000줄)를 덤프해 "마지막 몇 분"만 나오던 것을 실시간 세션 파일을 그대로 내보내도록 변경. - disconnected() 가 비정상 종료 경로에서만 호출돼, 사용자가 직접 끊으면 아무 기록 없이 로그가 끊겼다. 이유(user/unexpected/gatt_error)를 구분해 항상 남기도록 수정. 임상 모드 step 별 ble.log 는 부가 사본으로 그대로 유지한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -102,6 +102,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { /* silent · 종료 시점 · UC-05 */ }
|
} catch (_: Exception) { /* silent · 종료 시점 · UC-05 */ }
|
||||||
|
// BLE 로그 파일 마무리. 줄마다 flush 하므로 이 호출을 놓쳐도(강제 종료 등)
|
||||||
|
// 그 시점까지의 내용은 이미 디스크에 있다 — 여기서는 마무리 표시만 남긴다.
|
||||||
|
try {
|
||||||
|
com.medithings.vesiscan.services.BleDebugLogger.getInstance(applicationContext).close()
|
||||||
|
} catch (_: Exception) {}
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1315,8 +1315,15 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
|
|
||||||
gatt.close()
|
gatt.close()
|
||||||
|
|
||||||
|
// 위와 같은 이유로 항상 남긴다 (gatt_error 경로).
|
||||||
|
if (wasConnected) {
|
||||||
|
debugLogger.disconnected(
|
||||||
|
if (isUserDisconnect) "user_gatt_error" else "gatt_error",
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (wasConnected && lastConnectedAddress != null && !isUserDisconnect) {
|
if (wasConnected && lastConnectedAddress != null && !isUserDisconnect) {
|
||||||
debugLogger.disconnected("gatt_error", status)
|
|
||||||
onUnexpectedDisconnect?.invoke()
|
onUnexpectedDisconnect?.invoke()
|
||||||
scheduleAutoReconnect()
|
scheduleAutoReconnect()
|
||||||
} else if (reconnectAttempt > 0 && lastConnectedAddress != null) {
|
} else if (reconnectAttempt > 0 && lastConnectedAddress != null) {
|
||||||
@@ -1422,8 +1429,15 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
|
|
||||||
onConnectionStateChanged?.invoke(false)
|
onConnectionStateChanged?.invoke(false)
|
||||||
|
|
||||||
|
// 2026-08-24: 끊긴 이유와 무관하게 **항상** 로그에 남긴다.
|
||||||
|
// 이전에는 아래 if 안에서만 기록해서, 사용자가 직접 끊으면
|
||||||
|
// (isUserDisconnect=true) 아무 표시 없이 로그가 그냥 끊겼다.
|
||||||
|
debugLogger.disconnected(
|
||||||
|
if (isUserDisconnect) "user" else "unexpected",
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
|
||||||
if (!isUserDisconnect && lastConnectedAddress != null) {
|
if (!isUserDisconnect && lastConnectedAddress != null) {
|
||||||
debugLogger.disconnected("unexpected", status)
|
|
||||||
onUnexpectedDisconnect?.invoke()
|
onUnexpectedDisconnect?.invoke()
|
||||||
scheduleAutoReconnect()
|
scheduleAutoReconnect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,33 +81,103 @@ class BleDebugLogger private constructor(private val context: Context) {
|
|||||||
private val dateFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
private val dateFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||||
private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
|
|
||||||
// 실시간 파일 기록 (화면 잠금/앱 종료에도 보존)
|
// ── 실시간 파일 기록 ────────────────────────────────────────────────────
|
||||||
// 임상 모드: 세션 step 폴더의 ble.log로 라우팅 (step 전환 시 새 파일)
|
//
|
||||||
// 일반 모드: Downloads/VesiScan_BLE_*.log (BLE 연결 1회당 1파일)
|
// 2026-08-24 개편. 이전에는 세 가지 이유로 로그가 통으로 안 남았다:
|
||||||
private var liveLogFile: File? = null
|
// 1) connected() 가 파일 핸들을 버려서 **BLE 재연결마다 새 파일**이 생겼다.
|
||||||
private var liveLogLabel: String = "" // 임상 모드 측정 단위 변경 감지
|
// 앱 시작~첫 연결 구간도 또 다른 파일에 들어갔다.
|
||||||
private fun ensureLiveLogFile(): File {
|
// 2) appendToFile 에 동기화가 없었다. rx() 는 BLE 콜백 스레드에서, 나머지는
|
||||||
val clinicalDir = ClinicalSessionStore.currentLogDir()
|
// UI 스레드에서 불리므로 동시에 appendText 하면 줄이 섞이거나 유실된다.
|
||||||
if (clinicalDir != null) {
|
// 3) Downloads 쓰기가 실패해도 예외를 삼켜서 **아무 데도 안 남았다**.
|
||||||
clinicalDir.mkdirs()
|
//
|
||||||
val currentLabel = ClinicalSessionStore.currentLabel()
|
// 이제 **프로세스 1회당 파일 1개**다. 앱이 켜져서 죽을 때까지 한 파일에 이어 쓴다.
|
||||||
// 측정 단위가 바뀌었으면 새 파일로 전환
|
// 줄마다 flush 하므로 앱이 강제 종료돼도 그 시점까지는 디스크에 남는다.
|
||||||
if (liveLogFile == null || liveLogLabel != currentLabel) {
|
|
||||||
liveLogFile = File(clinicalDir, "ble.log")
|
/** 이 프로세스가 시작된 시각 — 세션 파일 이름이자 로그의 기준점. */
|
||||||
liveLogLabel = currentLabel
|
private val processStart: Long = System.currentTimeMillis()
|
||||||
}
|
|
||||||
} else if (liveLogFile == null || liveLogLabel.isNotEmpty()) {
|
private val fileLock = Any()
|
||||||
// 일반 모드 진입 또는 임상 모드 종료
|
private var sessionWriter: java.io.Writer? = null
|
||||||
val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS)
|
private var sessionWriterFailed = false
|
||||||
liveLogFile = File(downloadsDir, "VesiScan_BLE_${dateFmt.format(Date())}.log")
|
|
||||||
liveLogLabel = ""
|
/** 이 프로세스의 통합 로그 파일. Downloads 가 막히면 앱 내부 저장소로 떨어진다. */
|
||||||
}
|
var sessionFile: File? = null
|
||||||
return liveLogFile!!
|
private set
|
||||||
}
|
|
||||||
private fun appendToFile(line: String) {
|
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 {
|
try {
|
||||||
ensureLiveLogFile().appendText(line + "\n")
|
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")
|
||||||
|
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) {}
|
} 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 ──
|
// ── Logging Methods ──
|
||||||
@@ -144,11 +214,10 @@ class BleDebugLogger private constructor(private val context: Context) {
|
|||||||
sessionStart = System.currentTimeMillis()
|
sessionStart = System.currentTimeMillis()
|
||||||
txCount = 0; rxCount = 0; errorCount = 0; packetTimeouts = 0; channelMissCount = 0
|
txCount = 0; rxCount = 0; errorCount = 0; packetTimeouts = 0; channelMissCount = 0
|
||||||
rssiHistory.clear()
|
rssiHistory.clear()
|
||||||
liveLogFile = null // 새 세션 → 새 파일
|
// ⚠️ 여기서 파일을 새로 만들지 않는다. 재연결마다 파일이 갈라지던 원인이었다.
|
||||||
appendToFile("=== VesiScan BLE Debug Log ===")
|
// 통계(tx/rx/err)만 리셋하고 파일에는 구분선을 남긴다.
|
||||||
appendToFile("Device: $name")
|
appendToFile("")
|
||||||
appendToFile("Session: ${dateFmt.format(Date())}")
|
appendToFile("─── CONNECTED $name ($address) ${dateFmt.format(Date())} ───")
|
||||||
appendToFile("============================================================")
|
|
||||||
add(Entry(System.currentTimeMillis(), Level.CONN, "CONNECTED $name ($address)"))
|
add(Entry(System.currentTimeMillis(), Level.CONN, "CONNECTED $name ($address)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,19 +351,17 @@ class BleDebugLogger private constructor(private val context: Context) {
|
|||||||
return file
|
return file
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Downloads 폴더에 저장 (파일 탐색기에서 바로 접근 가능) */
|
/**
|
||||||
|
* 내보내기 — **이미 실시간으로 쌓고 있는 세션 파일을 그대로 준다.**
|
||||||
|
*
|
||||||
|
* 예전에는 메모리 링버퍼([MAX_MEMORY_ENTRIES] = 2000줄)를 새 파일로 덤프해서
|
||||||
|
* "마지막 몇 분"만 나왔다. 세션 파일에는 프로세스 시작부터 전부 들어 있다.
|
||||||
|
*/
|
||||||
fun exportToDownloads(): File? {
|
fun exportToDownloads(): File? {
|
||||||
return try {
|
synchronized(fileLock) {
|
||||||
val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(
|
try { sessionWriter()?.flush() } catch (_: Exception) {}
|
||||||
android.os.Environment.DIRECTORY_DOWNLOADS
|
|
||||||
)
|
|
||||||
val fileName = "VesiScan_BLE_${dateFmt.format(Date())}.log"
|
|
||||||
val file = File(downloadsDir, fileName)
|
|
||||||
file.bufferedWriter().use { writeLogContent(it) }
|
|
||||||
file
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
return sessionFile?.takeIf { it.exists() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun exportToJsonFile(): File {
|
fun exportToJsonFile(): File {
|
||||||
|
|||||||
Reference in New Issue
Block a user