diff --git a/app/src/main/java/com/medithings/vesiscan/MainActivity.kt b/app/src/main/java/com/medithings/vesiscan/MainActivity.kt index f6ff681..c8d5c98 100644 --- a/app/src/main/java/com/medithings/vesiscan/MainActivity.kt +++ b/app/src/main/java/com/medithings/vesiscan/MainActivity.kt @@ -102,6 +102,11 @@ class MainActivity : ComponentActivity() { } } } catch (_: Exception) { /* silent · 종료 시점 · UC-05 */ } + // BLE 로그 파일 마무리. 줄마다 flush 하므로 이 호출을 놓쳐도(강제 종료 등) + // 그 시점까지의 내용은 이미 디스크에 있다 — 여기서는 마무리 표시만 남긴다. + try { + com.medithings.vesiscan.services.BleDebugLogger.getInstance(applicationContext).close() + } catch (_: Exception) {} super.onDestroy() } } diff --git a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt index 25a8187..8ef1a59 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -1315,8 +1315,15 @@ class BleManager private constructor(private val context: Context) { gatt.close() + // 위와 같은 이유로 항상 남긴다 (gatt_error 경로). + if (wasConnected) { + debugLogger.disconnected( + if (isUserDisconnect) "user_gatt_error" else "gatt_error", + status, + ) + } + if (wasConnected && lastConnectedAddress != null && !isUserDisconnect) { - debugLogger.disconnected("gatt_error", status) onUnexpectedDisconnect?.invoke() scheduleAutoReconnect() } else if (reconnectAttempt > 0 && lastConnectedAddress != null) { @@ -1422,8 +1429,15 @@ class BleManager private constructor(private val context: Context) { onConnectionStateChanged?.invoke(false) + // 2026-08-24: 끊긴 이유와 무관하게 **항상** 로그에 남긴다. + // 이전에는 아래 if 안에서만 기록해서, 사용자가 직접 끊으면 + // (isUserDisconnect=true) 아무 표시 없이 로그가 그냥 끊겼다. + debugLogger.disconnected( + if (isUserDisconnect) "user" else "unexpected", + status, + ) + if (!isUserDisconnect && lastConnectedAddress != null) { - debugLogger.disconnected("unexpected", status) onUnexpectedDisconnect?.invoke() scheduleAutoReconnect() } diff --git a/app/src/main/java/com/medithings/vesiscan/services/BleDebugLogger.kt b/app/src/main/java/com/medithings/vesiscan/services/BleDebugLogger.kt index dbb11c1..bbe5a31 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/BleDebugLogger.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/BleDebugLogger.kt @@ -81,33 +81,103 @@ class BleDebugLogger private constructor(private val context: Context) { private val dateFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US) private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US) - // 실시간 파일 기록 (화면 잠금/앱 종료에도 보존) - // 임상 모드: 세션 step 폴더의 ble.log로 라우팅 (step 전환 시 새 파일) - // 일반 모드: Downloads/VesiScan_BLE_*.log (BLE 연결 1회당 1파일) - private var liveLogFile: File? = null - private var liveLogLabel: String = "" // 임상 모드 측정 단위 변경 감지 - private fun ensureLiveLogFile(): File { - val clinicalDir = ClinicalSessionStore.currentLogDir() - if (clinicalDir != null) { - clinicalDir.mkdirs() - val currentLabel = ClinicalSessionStore.currentLabel() - // 측정 단위가 바뀌었으면 새 파일로 전환 - if (liveLogFile == null || liveLogLabel != currentLabel) { - liveLogFile = File(clinicalDir, "ble.log") - liveLogLabel = currentLabel - } - } else if (liveLogFile == null || liveLogLabel.isNotEmpty()) { - // 일반 모드 진입 또는 임상 모드 종료 - val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS) - liveLogFile = File(downloadsDir, "VesiScan_BLE_${dateFmt.format(Date())}.log") - liveLogLabel = "" + // ── 실시간 파일 기록 ──────────────────────────────────────────────────── + // + // 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") + w.write("=".repeat(60) + "\n") + w.flush() + return w + } catch (_: Exception) { /* 다음 후보로 */ } } - return liveLogFile!! + 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) { - try { - ensureLiveLogFile().appendText(line + "\n") - } catch (_: Exception) {} + 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 ── @@ -144,11 +214,10 @@ class BleDebugLogger private constructor(private val context: Context) { sessionStart = System.currentTimeMillis() txCount = 0; rxCount = 0; errorCount = 0; packetTimeouts = 0; channelMissCount = 0 rssiHistory.clear() - liveLogFile = null // 새 세션 → 새 파일 - appendToFile("=== VesiScan BLE Debug Log ===") - appendToFile("Device: $name") - appendToFile("Session: ${dateFmt.format(Date())}") - appendToFile("============================================================") + // ⚠️ 여기서 파일을 새로 만들지 않는다. 재연결마다 파일이 갈라지던 원인이었다. + // 통계(tx/rx/err)만 리셋하고 파일에는 구분선을 남긴다. + appendToFile("") + appendToFile("─── CONNECTED $name ($address) ${dateFmt.format(Date())} ───") add(Entry(System.currentTimeMillis(), Level.CONN, "CONNECTED $name ($address)")) } @@ -282,19 +351,17 @@ class BleDebugLogger private constructor(private val context: Context) { return file } - /** Downloads 폴더에 저장 (파일 탐색기에서 바로 접근 가능) */ + /** + * 내보내기 — **이미 실시간으로 쌓고 있는 세션 파일을 그대로 준다.** + * + * 예전에는 메모리 링버퍼([MAX_MEMORY_ENTRIES] = 2000줄)를 새 파일로 덤프해서 + * "마지막 몇 분"만 나왔다. 세션 파일에는 프로세스 시작부터 전부 들어 있다. + */ fun exportToDownloads(): File? { - return try { - val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory( - 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 + synchronized(fileLock) { + try { sessionWriter()?.flush() } catch (_: Exception) {} } + return sessionFile?.takeIf { it.exists() } } fun exportToJsonFile(): File {