refactor: 데모 브랜치 패키지 통일 com.example.medilightv2android → com.medithings.vesiscan

사용자앱 (feature/tab-navigation) 과 패키지 이름 일치. namespace + applicationId
둘 다 com.medithings.vesiscan 로 변경 (기존 데모 앱은 재설치 필요).

## 변경 범위
- Kotlin 148 파일: package + import 문 (714 occurrences)
- 디렉토리 이동: com/example/medilightv2android → com/medithings/vesiscan
  (main, test, androidTest 각각)
- app/build.gradle.kts: namespace, applicationId
- docs/FLAVOR_DEMO_STABLE.md: 참조 갱신
- V41DetectorCH4Test: BvDispatchResult.methodChosen → method (dto field name fix)

## 주의
- applicationId 가 바뀌므로 기존 데모 앱 (com.example.medilightv2android.demo) 은
  Android 관점에서 다른 앱으로 취급 — 재설치 시 PIN/설정 초기화됨.
- Fresh install 권장. 기존 앱 (com.example...) 은 별도로 uninstall 필요.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 14:30:43 +09:00
parent 55d55225ab
commit bcbc7b440c
159 changed files with 2865 additions and 717 deletions
@@ -0,0 +1,102 @@
package com.medithings.vesiscan.services
import android.os.Environment
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
/**
* ADC 데이터를 세션별 CSV 파일로 저장.
*
* 파일 위치: Downloads/VesiScan_ADC/YYYY-MM-DD_HHmmss.csv
* newSession() 호출 시 새 파일로 전환 (BLE 연결 시 자동, 수동 호출 가능)
*/
object AdcCsvLogger {
private val sessionFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
var lastScanId: Int = 0
private set
private var scanCounter = 0
private var sessionFile: File? = null
private fun getDir(): File {
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"VesiScan_ADC"
)
if (!dir.exists()) dir.mkdirs()
return dir
}
fun newSession() {
scanCounter = 0
lastScanId = 0
sessionFile = File(getDir(), "${sessionFmt.format(Date())}.csv")
}
/**
* 임상 모드: ClinicalSessionStore에 활성 세션이 있으면 그 step 폴더의 adc.csv 사용.
* 일반 모드: 기존 세션 파일 사용.
*/
private fun getFile(): File {
val clinicalDir = ClinicalSessionStore.currentLogDir()
if (clinicalDir != null) {
clinicalDir.mkdirs()
return File(clinicalDir, "adc.csv")
}
if (sessionFile == null) newSession()
return sessionFile!!
}
fun log(
channels: List<List<UShort>>,
volumeMl: Double? = null,
lrRatio: Double = 1.0
) {
try {
val file = getFile()
val isNew = !file.exists() || file.length() == 0L
scanCounter++
lastScanId = scanCounter
val timestamp = timeFmt.format(Date())
val volStr = volumeMl?.let { "%.1f".format(it) } ?: ""
val lrStr = "%.2f".format(lrRatio)
val analyzer = com.medithings.vesiscan.managers.PiezoEchoAnalyzer.shared
val thrStr = if (analyzer.useAdaptiveThreshold && analyzer.lastOtsuThreshold > 0)
"%.0f".format(analyzer.lastOtsuThreshold) else "%.0f".format(com.medithings.vesiscan.managers.GreenZoneConstants.lowEchoAmp)
val dpsStr = "%.3f".format(com.medithings.vesiscan.managers.PiezoHW.distancePerSample)
val sgStr = if (analyzer.useSgFilter) "ON" else "OFF"
val clinicalLabel = ClinicalSessionStore.currentLabel() // 일반 모드면 ""
// BLE 펌웨어 버전 (연결 안 됐으면 "")
val fwVersion = com.medithings.vesiscan.ble.BleManager.current?.firmwareVersion?.value ?: ""
java.io.FileWriter(file, !isNew).buffered().use { writer ->
if (isNew) {
val header = buildString {
append("scan_id,timestamp,clinical_label,firmware_version,volume_ml,lr_ratio,threshold,dps,sg_filter,channel")
for (i in 0 until 100) append(",s$i")
}
writer.write(header)
writer.newLine()
}
for ((chIdx, buffer) in channels.withIndex()) {
val row = buildString {
append("$scanCounter,$timestamp,$clinicalLabel,$fwVersion,$volStr,$lrStr,$thrStr,$dpsStr,$sgStr,CH$chIdx")
for (sample in buffer) {
append(",${sample.toInt()}")
}
for (i in buffer.size until 100) {
append(",")
}
}
writer.write(row)
writer.newLine()
}
}
} catch (_: Exception) {}
}
}
@@ -0,0 +1,349 @@
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)
// 실시간 파일 기록 (화면 잠금/앱 종료에도 보존)
// 임상 모드: 세션 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 = ""
}
return liveLogFile!!
}
private fun appendToFile(line: String) {
try {
ensureLiveLogFile().appendText(line + "\n")
} catch (_: Exception) {}
}
// ── 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()
liveLogFile = null // 새 세션 → 새 파일
appendToFile("=== VesiScan BLE Debug Log ===")
appendToFile("Device: $name")
appendToFile("Session: ${dateFmt.format(Date())}")
appendToFile("============================================================")
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) {
val scanId = AdcCsvLogger.lastScanId
val msg = buildString {
append("MEASUREMENT scan=$scanId")
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
}
/** Downloads 폴더에 저장 (파일 탐색기에서 바로 접근 가능) */
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
}
}
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() }
}
}
@@ -0,0 +1,155 @@
package com.medithings.vesiscan.services
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import com.medithings.vesiscan.MainActivity
import com.medithings.vesiscan.R
/**
* BLE 연결 유지를 위한 Foreground Service.
*
* 화면이 꺼져도 Android Doze 모드에서 앱을 유지하여
* BLE 통신(TX/RX), watchdog, Auto 측정이 중단되지 않도록 한다.
*/
class BleForegroundService : Service() {
companion object {
const val CHANNEL_ID = "vesiscan_ble_channel"
const val NOTIFICATION_ID = 1001
const val ACTION_START = "com.medithings.vesiscan.BLE_START"
const val ACTION_STOP = "com.medithings.vesiscan.BLE_STOP"
fun start(context: Context) {
// 배터리 최적화 제외 요청 제거 — 다른 페이지로 이동되는 UX 문제
// Foreground Service + PARTIAL_WAKE_LOCK만으로 Doze 대응
val intent = Intent(context, BleForegroundService::class.java).apply {
action = ACTION_START
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
@android.annotation.SuppressLint("BatteryLife")
private fun requestBatteryOptimizationExemption(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
if (!pm.isIgnoringBatteryOptimizations(context.packageName)) {
try {
val intent = Intent(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (_: Exception) {}
}
}
}
fun stop(context: Context) {
val intent = Intent(context, BleForegroundService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
}
}
private var wakeLock: PowerManager.WakeLock? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
else -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, buildNotification(),
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
} else {
startForeground(NOTIFICATION_ID, buildNotification())
}
acquireWakeLock()
}
}
return START_STICKY
}
override fun onDestroy() {
releaseWakeLock()
super.onDestroy()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"VesiScan BLE Connection",
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.ble_service_channel_desc)
setShowBadge(false)
}
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(channel)
}
}
private fun buildNotification(): Notification {
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("VesiScan")
.setContentText(getString(R.string.ble_service_connecting))
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setOngoing(true)
.setContentIntent(pendingIntent)
.build()
}
private fun acquireWakeLock() {
if (wakeLock == null) {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"VesiScan::BleWakeLock"
).apply {
acquire(4 * 60 * 60 * 1000L) // 최대 4시간
}
}
}
private fun releaseWakeLock() {
wakeLock?.let {
if (it.isHeld) it.release()
}
wakeLock = null
}
}
@@ -0,0 +1,338 @@
package com.medithings.vesiscan.services
import android.content.Context
import android.os.Environment
import com.medithings.vesiscan.ble.ImuSample
import com.medithings.vesiscan.ble.PiezoChannelData
import com.medithings.vesiscan.models.ClinicalSession
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** 한 측정 사이클 = mtb 응답 1회 = 6채널 piezo + 15 IMU 샘플.
* alignment 모드일 때 alignXxx 필드들이 채워짐 (V1 advisor 출력). */
data class MeasurementCycle(
val cycle: Int,
val timestampMs: Long,
val piezoChannels: Map<Int, List<Int>>, // channel idx → samples
val imuSamples: List<ImuSample>,
// Alignment mode metadata (V1 placementPhase, detected channels, urineLens)
val alignPhase: String = "", // VERTICAL / LATERAL / GREEN / ""
val alignScore: Int = 0, // placementScore
val alignDetected: List<Boolean> = emptyList(), // 6개 채널 검출 여부
val alignUrineLens: List<Int> = emptyList(), // 6개 채널 urineLen
val alignHint: String = "", // directionHint
val alignIcon: String = "", // directionIcon
)
/**
* 임상 측정 단위(한 측정 = 한 라벨 = 한 폴더) 전역 store.
*
* 사용자가 BLE 연결 후 라벨을 입력하고 startMeasurement()를 호출하면
* Downloads/VesiScan_Sessions/{folderName}/ 폴더가 생성되고 meta.json이 작성됨.
* 이후 모든 로거(AdcCsvLogger / MeasurementLogService / BleDebugLogger)가
* 해당 폴더로 자동 라우팅. endMeasurement()에서 meta.json이 finalize됨.
*
* zip은 사용하지 않음 — 폴더 자체가 분석 단위.
*/
object ClinicalSessionStore {
/** labdb 자료 구분 코드 — "001" = 내부 임상 R&D (외부 일반 VesiScan "000"과 구분). */
const val DATA_TYPE: String = "001"
/** ClinicalHome 화면에 진입한 상태인지. BLE 연결을 위한 DeviceScan 우회/Back 라우팅에 사용. */
@Volatile
var inClinicalFlow: Boolean = false
@Volatile
var currentSession: ClinicalSession? = null
private set
/** 마지막으로 종료된 측정 (UI에서 "Saved: ..." 표시용) */
@Volatile
var lastFinalizedFolder: File? = null
private set
/** 다음 측정 시 기본값으로 자동 채울 사용자 입력 — 같은 피험자/검사자 여러 측정 시 편의 */
var lastExaminer: String = ""
var lastNotes: String = ""
var lastSubjectType: com.medithings.vesiscan.models.SubjectType =
com.medithings.vesiscan.models.SubjectType.PHANTOM
var lastSubject: String = "" // 카테고리 내 식별자 (예: "500mL", "S001")
var lastTrueVolumeMl: String = "" // 문자열로 보관 (입력 폼과 동일 형식)
var lastAbdomenThicknessMm: String = ""
// 회의 결정: Step/Posture 마지막 선택 유지 — 매번 0cm로 리셋되는 불편 제거
var lastPosture: com.medithings.vesiscan.models.ClinicalPosture =
com.medithings.vesiscan.models.ClinicalPosture.SUPINE
var lastStep: com.medithings.vesiscan.models.ClinicalStep =
com.medithings.vesiscan.models.ClinicalStep.ALIGN_0CM
/** End 시점에 measurement.json 통합 생성용 — 측정 중 사이클 누적 */
private val cyclesBuffer = mutableListOf<MeasurementCycle>()
fun startMeasurement(context: Context, session: ClinicalSession) {
currentSession = session
lastExaminer = session.examiner
lastNotes = session.notes
lastSubjectType = session.subjectType
lastSubject = session.subject
lastTrueVolumeMl = session.trueVolumeMl?.toString() ?: ""
lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: ""
lastPosture = session.posture
lastStep = session.step
cyclesBuffer.clear()
currentLogDir()?.mkdirs()
writeMeta(context)
}
/** 한 사이클(piezo + IMU) 누적 — End 시 measurement.json에 합쳐짐 */
fun addCycle(piezo: List<PiezoChannelData>, imu: List<ImuSample>) {
if (currentSession == null) return
cyclesBuffer.add(MeasurementCycle(
cycle = cyclesBuffer.size,
timestampMs = System.currentTimeMillis(),
piezoChannels = piezo.sortedBy { it.channel }
.associate { it.channel to it.buffer.map { s -> s.toInt() } },
imuSamples = imu.toList(),
))
}
/** Alignment 모드 frame 누적 — V1 advisor 출력 포함 (phase / score / detected / urineLens / hint). */
fun addAlignmentFrame(
piezo: List<PiezoChannelData>,
imu: List<ImuSample>,
alignPhase: String,
alignScore: Int,
alignDetected: List<Boolean>,
alignUrineLens: List<Int>,
alignHint: String,
alignIcon: String,
) {
if (currentSession == null) return
cyclesBuffer.add(MeasurementCycle(
cycle = cyclesBuffer.size,
timestampMs = System.currentTimeMillis(),
piezoChannels = piezo.sortedBy { it.channel }
.associate { it.channel to it.buffer.map { s -> s.toInt() } },
imuSamples = imu.toList(),
alignPhase = alignPhase,
alignScore = alignScore,
alignDetected = alignDetected,
alignUrineLens = alignUrineLens,
alignHint = alignHint,
alignIcon = alignIcon,
))
}
val cycleCount: Int get() = cyclesBuffer.size
/** 현재 측정의 폴더 (= 모든 로거의 저장 디렉토리). */
fun currentLogDir(): File? {
val s = currentSession ?: return null
val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
return File(File(downloads, "VesiScan_Sessions"), s.folderName)
}
/** clinical_label 컬럼/필드용. 비활성 시 "" */
fun currentLabel(): String = currentSession?.label ?: ""
/**
* 폴더명 정보를 파일명에 포함시켜 분석 PC로 옮긴 후에도 식별 가능하게.
* 예: meta_PHANTOM_VBT26050202_SUPINE_ALIGN_0CM_2026-06-09_15-30-00.json
*/
private fun metaJsonFile(dir: File): File = File(dir, "meta_${dir.name}.json")
private fun measurementJsonFile(dir: File): File = File(dir, "measurement_${dir.name}.json")
fun writeMeta(context: Context) {
val s = currentSession ?: return
val dir = currentLogDir() ?: return
dir.mkdirs()
val meta = JSONObject().apply {
put("data_type", DATA_TYPE)
put("device_name", s.deviceName)
put("posture", s.posture.name)
put("step", s.step.name)
put("is_alignment", s.step.isAlignment)
put("examiner", s.examiner)
put("notes", s.notes)
put("label", s.label)
// 측정 대상 분류 (회의 결정: Phantom/Human/ETC)
put("subject_type", s.subjectType.name)
if (s.subject.isNotBlank()) put("subject", s.subject)
// 임상 시험 참값 (외부 측정기, vesiscan_test 프로토콜)
s.trueVolumeMl?.let { put("true_volume_ml", it) }
s.abdomenThicknessMm?.let { put("abdomen_thickness_mm", it) }
put("started_at", s.startedAt)
put("started_at_iso",
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(s.startedAt)))
s.endedAt?.let {
put("ended_at", it)
put("ended_at_iso",
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(it)))
put("duration_ms", it - s.startedAt)
}
put("app_version_name", try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
} catch (_: Exception) { "unknown" })
put("app_version_code", try {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionCode
} catch (_: Exception) { -1 })
put("device_model", android.os.Build.MODEL)
put("device_manufacturer", android.os.Build.MANUFACTURER)
put("android_release", android.os.Build.VERSION.RELEASE)
put("android_sdk", android.os.Build.VERSION.SDK_INT)
// VBT 디바이스 정보 (mid?/rid: 응답 파싱값) — 분석 시 펌웨어별 데이터 grouping용
val ble = com.medithings.vesiscan.ble.BleManager.getInstance(context)
val fw = ble.firmwareVersion.value
val hw = ble.hardwareVersion.value
val sn = ble.serialNumber.value
if (fw.isNotEmpty()) put("firmware_version", fw)
if (hw.isNotEmpty()) put("hardware_version", hw)
if (sn.isNotEmpty()) put("serial_number", sn)
}
metaJsonFile(dir).writeText(meta.toString(2))
}
/** 측정 종료: endedAt → meta.json 갱신 → measurement.json 통합 생성 → currentSession=null.
* labdb 업로드는 자동 X — Clinical Home 의 'Last saved' 카드의 Upload 버튼으로 수동 트리거. */
fun endMeasurement(context: Context): File? {
val s = currentSession ?: return null
s.endedAt = System.currentTimeMillis()
writeMeta(context)
writeMeasurementJson(context)
val dir = currentLogDir()
lastFinalizedFolder = dir
currentSession = null
cyclesBuffer.clear()
return dir
}
/**
* End Session 시점에 모든 사이클을 단일 measurement.json으로 통합.
* 분석 PC에서 한 줄 로드: data = json.load(open('measurement.json'))
*/
private fun writeMeasurementJson(context: Context) {
val s = currentSession ?: return
val dir = currentLogDir() ?: return
dir.mkdirs()
try {
val root = JSONObject()
// meta: writeMeta()와 동일 내용 포함 (단독 파일로 자족 가능)
// alignment 모드 → dataType=002, 그 외 = 001 (기존 measurement)
val effectiveDataType = if (s.mode == com.medithings.vesiscan.models.SessionMode.ALIGNMENT)
"002" else DATA_TYPE
val meta = JSONObject().apply {
put("data_type", effectiveDataType)
put("session_mode", s.mode.name)
if (s.alignmentAlgo.isNotBlank()) put("alignment_algo", s.alignmentAlgo)
put("subject_type", s.subjectType.name)
if (s.subject.isNotBlank()) put("subject", s.subject)
put("device_name", s.deviceName)
put("posture", s.posture.name)
put("step", s.step.name)
put("is_alignment", s.step.isAlignment)
put("examiner", s.examiner)
put("notes", s.notes)
put("label", s.label)
s.trueVolumeMl?.let { put("true_volume_ml", it) }
s.abdomenThicknessMm?.let { put("abdomen_thickness_mm", it) }
put("started_at", s.startedAt)
put("started_at_iso",
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(s.startedAt)))
s.endedAt?.let {
put("ended_at", it)
put("ended_at_iso",
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(it)))
put("duration_ms", it - s.startedAt)
}
put("captured_cycles", cyclesBuffer.size)
put("app_version_name", try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
} catch (_: Exception) { "unknown" })
val ble = com.medithings.vesiscan.ble.BleManager.current
ble?.firmwareVersion?.value?.takeIf { it.isNotEmpty() }?.let { put("firmware_version", it) }
ble?.hardwareVersion?.value?.takeIf { it.isNotEmpty() }?.let { put("hardware_version", it) }
ble?.serialNumber?.value?.takeIf { it.isNotEmpty() }?.let { put("serial_number", it) }
}
root.put("meta", meta)
// cycles: 모든 사이클을 piezo + imu 묶음으로
val cyclesArr = JSONArray()
for (c in cyclesBuffer) {
val piezo = JSONObject()
c.piezoChannels.forEach { (ch, samples) ->
piezo.put("CH$ch", JSONArray(samples))
}
val imuArr = JSONArray()
for (sample in c.imuSamples) {
imuArr.put(JSONObject().apply {
put("ax", sample.ax.toDouble())
put("ay", sample.ay.toDouble())
put("az", sample.az.toDouble())
put("gx", sample.gx.toDouble())
put("gy", sample.gy.toDouble())
put("gz", sample.gz.toDouble())
})
}
cyclesArr.put(JSONObject().apply {
put("cycle", c.cycle)
put("timestamp_ms", c.timestampMs)
put("piezo", piezo)
put("imu", imuArr)
// Alignment metadata (alignment mode 일 때만 채워짐)
if (c.alignPhase.isNotBlank()) {
put("align_phase", c.alignPhase)
put("align_score", c.alignScore)
put("align_hint", c.alignHint)
put("align_icon", c.alignIcon)
put("align_detected", JSONArray(c.alignDetected))
put("align_urine_lens", JSONArray(c.alignUrineLens))
}
})
}
root.put("cycles", cyclesArr)
measurementJsonFile(dir).writeText(root.toString(2))
} catch (_: Exception) {
// 저장 실패해도 측정 자체는 막지 않음
}
}
/** 측정 폐기 (메타/파일은 그대로 두되 store만 비움) */
fun discardMeasurement() {
currentSession = null
}
/**
* 측정 중인 세션을 즉시 폐기 + 폴더 완전 삭제 (잘못 측정 인지 시).
* endMeasurement()와 달리 meta.json/measurement.json/labdb 업로드 전부 skip.
* 반환: 삭제된 폴더 경로 (없으면 null).
*/
fun discardAndDeleteFolder(): File? {
val dir = currentLogDir()
currentSession = null
cyclesBuffer.clear()
if (dir != null && dir.exists()) {
dir.deleteRecursively()
}
return dir
}
/**
* 이미 finalize된 측정 폴더(lastFinalizedFolder)를 삭제.
* labdb 서버 데이터까지 제거하려면 호출자가 LabdbClient.deleteSession()도 별도 호출.
*/
fun deleteFinalizedFolder(): File? {
val dir = lastFinalizedFolder
if (dir != null && dir.exists()) {
dir.deleteRecursively()
}
lastFinalizedFolder = null
return dir
}
}
@@ -0,0 +1,66 @@
package com.medithings.vesiscan.services
import com.medithings.vesiscan.ble.ImuSample
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* IMU 샘플을 CSV로 저장. 임상 R&D 모드 한정.
*
* 파일 위치 (임상 모드일 때):
* Downloads/VesiScan_Sessions/{folder}/imu.csv
*
* 한 번의 mtb? 응답에서 15 샘플 → CSV 15행. scan_id로 같은 측정의 piezo adc.csv와 매칭 가능.
*
* Sensitivity:
* ax/ay/az: g (Accel ±4g, 1 LSB = 1/8192 g)
* gx/gy/gz: dps (Gyro ±500 dps, 1 LSB = 1/65.536 dps)
*
* Sample 순서: 한 응답 안에서 oldest→newest. sample_idx 컬럼으로 명시.
*/
object ImuCsvLogger {
private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
/**
* 임상 모드 한정. ClinicalSessionStore.currentLogDir()가 있을 때만 저장.
* samples를 oldest→newest 순서로 그대로 기록.
*/
fun log(samples: List<ImuSample>) {
val dir = ClinicalSessionStore.currentLogDir() ?: return
if (samples.isEmpty()) return
try {
dir.mkdirs()
val file = File(dir, "imu.csv")
val isNew = !file.exists() || file.length() == 0L
val scanId = AdcCsvLogger.lastScanId // piezo adc.csv와 매칭용
val timestamp = timeFmt.format(Date())
val clinicalLabel = ClinicalSessionStore.currentLabel()
val fwVersion = com.medithings.vesiscan.ble.BleManager.current?.firmwareVersion?.value ?: ""
file.bufferedWriter().use { _ -> } // ensure exists
java.io.FileWriter(file, !isNew).buffered().use { writer ->
if (isNew) {
writer.write("scan_id,timestamp,clinical_label,firmware_version,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps")
writer.newLine()
}
for ((idx, s) in samples.withIndex()) {
writer.write(buildString {
append("$scanId,$timestamp,$clinicalLabel,$fwVersion,$idx,")
append("%.4f".format(s.ax)); append(",")
append("%.4f".format(s.ay)); append(",")
append("%.4f".format(s.az)); append(",")
append("%.4f".format(s.gx)); append(",")
append("%.4f".format(s.gy)); append(",")
append("%.4f".format(s.gz))
})
writer.newLine()
}
}
} catch (_: Exception) {
// 로그는 측정 자체를 막으면 안 됨 — silent fail
}
}
}
@@ -0,0 +1,35 @@
package com.medithings.vesiscan.services
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
object KeychainService {
private const val FILE_NAME = "medilightv2_secure_prefs"
private var prefs: SharedPreferences? = null
fun init(context: Context) {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
prefs = EncryptedSharedPreferences.create(
context,
FILE_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
fun save(key: String, value: String) {
prefs?.edit()?.putString(key, value)?.apply()
}
fun load(key: String): String? = prefs?.getString(key, null)
fun delete(key: String) {
prefs?.edit()?.remove(key)?.apply()
}
}
@@ -0,0 +1,91 @@
package com.medithings.vesiscan.services
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
/**
* 페어링했던 BLE 기기 다수 영속화.
* SharedPreferences "medilight_prefs" 의 "known_devices" 키에 JSON array.
*
* 기존 단일 키(last_device_address/last_device_name) 는 마이그레이션 위해 1회 읽어 합치고
* BleManager 가 새로 add() 호출 시 같이 정리.
*/
data class KnownDevice(
val address: String,
val name: String,
val lastConnectedAt: Long,
)
object KnownDeviceStore {
private const val PREFS = "medilight_prefs"
private const val KEY = "known_devices"
fun getAll(context: Context): List<KnownDevice> {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = prefs.getString(KEY, null)
val list = mutableListOf<KnownDevice>()
if (!raw.isNullOrBlank()) {
runCatching {
val arr = JSONArray(raw)
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
val addr = o.optString("address").takeIf { it.isNotBlank() } ?: continue
val name = o.optString("name", "Unknown")
val ts = o.optLong("lastConnectedAt", 0L)
list.add(KnownDevice(addr, name, ts))
}
}
}
// 구 키 마이그레이션 — 한 번이라도 들어와 보면 list 에 흡수
val legacyAddr = prefs.getString("last_device_address", null)
val legacyName = prefs.getString("last_device_name", null)
if (!legacyAddr.isNullOrBlank() && list.none { it.address == legacyAddr }) {
list.add(KnownDevice(legacyAddr, legacyName ?: "Unknown", System.currentTimeMillis()))
saveAll(context, list)
}
return list.sortedByDescending { it.lastConnectedAt }
}
/** 새 연결 성공 시 호출 — 있으면 lastConnectedAt 갱신, 없으면 추가. 구 단일 키도 같이 채움. */
fun add(context: Context, address: String, name: String) {
val current = getAll(context).toMutableList()
val idx = current.indexOfFirst { it.address == address }
val now = System.currentTimeMillis()
if (idx >= 0) current[idx] = current[idx].copy(name = name, lastConnectedAt = now)
else current.add(KnownDevice(address, name, now))
saveAll(context, current)
// 구 키도 호환성 유지
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString("last_device_address", address)
.putString("last_device_name", name)
.apply()
}
/** 페어링 해제 시 호출 — known list 에서 제거. */
fun remove(context: Context, address: String) {
val current = getAll(context).filter { it.address != address }
saveAll(context, current)
// 구 키 정리 — 제거한 게 마지막 기기였다면
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (prefs.getString("last_device_address", null) == address) {
prefs.edit().remove("last_device_address").remove("last_device_name").apply()
}
}
fun mostRecent(context: Context): KnownDevice? = getAll(context).firstOrNull()
private fun saveAll(context: Context, list: List<KnownDevice>) {
val arr = JSONArray()
list.forEach { d ->
arr.put(JSONObject().apply {
put("address", d.address)
put("name", d.name)
put("lastConnectedAt", d.lastConnectedAt)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(KEY, arr.toString())
.apply()
}
}
@@ -0,0 +1,73 @@
/*
* LocalePref — 앱 언어 설정 영속화 + 컨텍스트 wrapping.
*
* 모드:
* - SYSTEM (default): 폰 시스템 언어 따라감 (Korean → 한국어, 그 외 → English)
* - KO : 강제 한국어
* - EN : 강제 영어
*
* 적용:
* 1. MainActivity.attachBaseContext(base) → wrap(base) 호출
* 2. 언어 변경 시 setMode(...) 호출 후 activity recreate (또는 process restart)
*
* Android 13+ 의 per-app locale API (AppCompatDelegate.setApplicationLocales) 와
* 무관하게 Configuration override 방식으로 모든 SDK 에서 동작.
*/
package com.medithings.vesiscan.services
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import java.util.Locale
object LocalePref {
enum class Mode { SYSTEM, KO, EN }
private const val PREF = "locale_pref"
private const val KEY_MODE = "mode"
fun getMode(context: Context): Mode {
val s = context.applicationContext
.getSharedPreferences(PREF, Context.MODE_PRIVATE)
.getString(KEY_MODE, Mode.SYSTEM.name) ?: Mode.SYSTEM.name
return runCatching { Mode.valueOf(s) }.getOrDefault(Mode.SYSTEM)
}
fun setMode(context: Context, mode: Mode) {
context.applicationContext
.getSharedPreferences(PREF, Context.MODE_PRIVATE)
.edit().putString(KEY_MODE, mode.name).apply()
}
/** 선택된 모드에 맞춰 Locale 적용된 새 Context 반환. attachBaseContext 에서 사용. */
fun wrap(base: Context): Context {
val mode = getMode(base)
if (mode == Mode.SYSTEM) return base // 시스템 기본 — 손대지 않음
val locale = when (mode) {
Mode.KO -> Locale.KOREAN
Mode.EN -> Locale.ENGLISH
else -> return base
}
Locale.setDefault(locale)
val config = Configuration(base.resources.configuration)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val list = android.os.LocaleList(locale)
android.os.LocaleList.setDefault(list)
config.setLocales(list)
} else {
@Suppress("DEPRECATION")
config.locale = locale
}
return base.createConfigurationContext(config)
}
/** 현재 적용된 locale 의 사용자 표시용 라벨. */
fun currentLabel(context: Context): String = when (getMode(context)) {
Mode.SYSTEM -> "시스템 기본 / System"
Mode.KO -> "한국어"
Mode.EN -> "English"
}
}
@@ -0,0 +1,47 @@
package com.medithings.vesiscan.services
import android.content.Context
import android.content.SharedPreferences
import com.medithings.vesiscan.models.MeasurementRecord
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
class MeasurementHistoryStore private constructor(context: Context) {
companion object {
@Volatile
private var instance: MeasurementHistoryStore? = null
fun getInstance(context: Context): MeasurementHistoryStore =
instance ?: synchronized(this) {
instance ?: MeasurementHistoryStore(context.applicationContext).also { instance = it }
}
private const val MAX_RECORDS = 100
}
private val prefs: SharedPreferences = context.getSharedPreferences("measurement_history", Context.MODE_PRIVATE)
private val gson = Gson()
private val key = "measurement_history"
var records: List<MeasurementRecord>
get() {
val json = prefs.getString(key, null) ?: return emptyList()
return try {
val type = object : TypeToken<List<MeasurementRecord>>() {}.type
gson.fromJson(json, type)
} catch (_: Exception) { emptyList() }
}
private set(value) {
val trimmed = if (value.size > MAX_RECORDS) value.take(MAX_RECORDS) else value
prefs.edit().putString(key, gson.toJson(trimmed)).apply()
}
fun addRecord(record: MeasurementRecord) {
val current = records.toMutableList()
current.add(0, record)
records = current
}
fun clearAll() {
prefs.edit().remove(key).apply()
}
}
@@ -0,0 +1,246 @@
package com.medithings.vesiscan.services
import android.content.Context
import android.os.Build
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
/**
* Measurement log auto-save service.
*
* All measurement results are saved in JSON Lines format to internal storage.
* Medical audit trail + QA verification.
*
* File location: vesiscan_logs/YYYY-MM-DD.jsonl
* Each line = one measurement event (JSON)
*/
class MeasurementLogService private constructor(private val context: Context) {
private val logDir: File
private val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private val timestampFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.US)
private val appVersionName: String = try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
} catch (_: Exception) { "unknown" }
private val appVersionCode: Int = try {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionCode
} catch (_: Exception) { -1 }
init {
// Downloads/VesiScan_Events/ 로 이동 — 앱 삭제 시 데이터 손실 방지.
// 내부 저장소(filesDir)는 앱 uninstall 시 같이 사라지지만, 외부 Downloads는 유지됨.
val downloads = android.os.Environment.getExternalStoragePublicDirectory(
android.os.Environment.DIRECTORY_DOWNLOADS
)
logDir = File(downloads, "VesiScan_Events")
if (!logDir.exists()) logDir.mkdirs()
}
companion object {
@Volatile
private var instance: MeasurementLogService? = null
fun getInstance(context: Context): MeasurementLogService {
return instance ?: synchronized(this) {
instance ?: MeasurementLogService(context.applicationContext).also { instance = it }
}
}
}
// -- Log Events --
/**
* Log a spot measurement result.
*/
fun logMeasurement(
volumeMl: Double?,
channels: List<ChannelLogEntry>,
rawADC: List<List<UShort>>
) {
val entry = JSONObject().apply {
put("event", "measurement")
put("timestamp", timestampFormatter.format(Date()))
put("device", Build.MODEL)
put("app_version", appVersionName)
put("app_version_code", appVersionCode)
val clinical = ClinicalSessionStore.currentLabel()
if (clinical.isNotEmpty()) put("clinical_label", clinical)
// BLE 펌웨어 버전 (연결 시 mid?/rid: 응답에서 받음)
val fw = com.medithings.vesiscan.ble.BleManager.current?.firmwareVersion?.value
if (!fw.isNullOrEmpty()) put("firmware_version", fw)
}
if (volumeMl != null) {
entry.put("volume_ml", Math.round(volumeMl * 10.0) / 10.0)
}
// Per-channel detection
val chArray = JSONArray()
for (ch in channels) {
val chObj = JSONObject().apply {
put("channel", ch.channel)
ch.ant?.let { put("ant", it) }
ch.post?.let { put("post", it) }
ch.urineLen?.let { put("urine_len", it) }
ch.score?.let { put("score", Math.round(it * 10.0) / 10.0) }
}
chArray.put(chObj)
}
entry.put("channels", chArray)
// Raw ADC (compact: comma-separated per channel)
val adcObj = JSONObject()
for ((i, adc) in rawADC.withIndex()) {
adcObj.put("CH$i", adc.joinToString(",") { it.toString() })
}
entry.put("adc", adcObj)
appendLog(entry)
}
/**
* Log Placement Guide result.
*
* 임상 모드에서는 구조화된 placements.jsonl로 별도 라우팅.
* 일반 모드에서는 기존 events JSONL 묶음에 그대로.
*/
fun logPlacement(
channelDetected: List<Boolean>,
direction: String,
urineLens: List<Int> = emptyList(),
cv: Double = Double.NaN,
lrDev: Double = Double.NaN,
phase: String = "",
score: Int = -1
) {
val entry = JSONObject().apply {
put("event", "placement")
put("timestamp", timestampFormatter.format(Date()))
val arr = JSONArray()
for (d in channelDetected) arr.put(d)
put("detected", arr)
put("detected_count", channelDetected.count { it })
put("direction", direction)
if (urineLens.isNotEmpty()) {
put("urine_lens", JSONArray().apply { urineLens.forEach { put(it) } })
put("urine_sum", urineLens.sum())
}
if (!cv.isNaN()) put("cv", cv)
if (!lrDev.isNaN()) put("lr_dev", lrDev)
if (phase.isNotEmpty()) put("phase", phase)
if (score >= 0) put("score", score)
val clinical = ClinicalSessionStore.currentLabel()
if (clinical.isNotEmpty()) put("clinical_label", clinical)
// BLE 펌웨어 버전
val fw = com.medithings.vesiscan.ble.BleManager.current?.firmwareVersion?.value
if (!fw.isNullOrEmpty()) put("firmware_version", fw)
}
appendLog(entry, "placements.jsonl")
}
/**
* Log BLE connection/disconnection.
*/
fun logConnection(event: String, deviceName: String = "") {
val entry = JSONObject().apply {
put("event", "ble_$event")
put("timestamp", timestampFormatter.format(Date()))
put("device_name", deviceName)
}
appendLog(entry)
}
/**
* Log an error.
*/
fun logError(message: String) {
val entry = JSONObject().apply {
put("event", "error")
put("timestamp", timestampFormatter.format(Date()))
put("message", message)
}
appendLog(entry)
}
// -- File Operations --
/**
* 임상 모드면 세션 step 폴더의 지정 파일에 추가.
* 일반 모드면 Downloads/VesiScan_Events/YYYY-MM-DD.jsonl 에 추가.
*/
private fun appendLog(entry: JSONObject, clinicalFileName: String = "events.jsonl") {
try {
val line = entry.toString() + "\n"
val clinicalDir = ClinicalSessionStore.currentLogDir()
val file = if (clinicalDir != null) {
clinicalDir.mkdirs()
File(clinicalDir, clinicalFileName)
} else {
File(logDir, dateFormatter.format(Date()) + ".jsonl")
}
file.appendText(line)
} catch (e: Exception) {
// Silently fail — logging should never crash the app
}
}
/** Today's log file */
val todayLogFile: File
get() = File(logDir, dateFormatter.format(Date()) + ".jsonl")
/** All log files, newest first */
fun listLogFiles(): List<File> {
return logDir.listFiles { f -> f.extension == "jsonl" }
?.sortedByDescending { it.name }
?: emptyList()
}
/** Read a log file as lines */
fun readLog(file: File): List<String> {
return try {
file.readLines().filter { it.isNotBlank() }
} catch (e: Exception) {
emptyList()
}
}
/** Today's log as text */
fun todayLogText(): String {
return try {
todayLogFile.readText()
} catch (e: Exception) {
"(no logs today)"
}
}
/** Total log size in bytes */
val totalLogSize: Long
get() = listLogFiles().sumOf { it.length() }
/** Purge logs older than [keepDays] days (default 365, R&D 데이터 장기 보관) */
fun purgeOldLogs(keepDays: Int = 365) {
val calendar = Calendar.getInstance()
calendar.add(Calendar.DAY_OF_YEAR, -keepDays)
val cutoffStr = dateFormatter.format(calendar.time)
for (file in listLogFiles()) {
val name = file.nameWithoutExtension
if (name < cutoffStr) {
file.delete()
}
}
}
}
/** Data class for channel log entries */
data class ChannelLogEntry(
val channel: Int,
val ant: Int? = null,
val post: Int? = null,
val urineLen: Int? = null,
val score: Double? = null
)
@@ -0,0 +1,50 @@
package com.medithings.vesiscan.services
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.models.SensorMode
class MeasurementService private constructor(context: Context) {
companion object {
@Volatile
private var instance: MeasurementService? = null
fun getInstance(context: Context): MeasurementService =
instance ?: synchronized(this) {
instance ?: MeasurementService(context.applicationContext).also { instance = it }
}
}
private val bleManager = BleManager.getInstance(context)
val isMeasuring = mutableStateOf(false)
fun performMeasurement(mode: SensorMode, completion: (Boolean) -> Unit) {
isMeasuring.value = true
when (mode) {
SensorMode.NIRS -> performNirsMeasurement(completion)
SensorMode.PIEZO -> performPiezoMeasurement(completion)
}
}
private fun performNirsMeasurement(completion: (Boolean) -> Unit) {
bleManager.onNirsMcjReceived = { _ ->
isMeasuring.value = false
completion(true)
}
bleManager.sendMcjQuery()
}
private fun performPiezoMeasurement(completion: (Boolean) -> Unit) {
bleManager.onPiezoDataReceived = { _ ->
isMeasuring.value = false
completion(true)
}
bleManager.sendBurst(freqOption = 2, cycles = 5, delayUs = 100)
}
fun stop() {
isMeasuring.value = false
bleManager.sendPiezoStop()
}
}
@@ -0,0 +1,77 @@
package com.medithings.vesiscan.services
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.medithings.vesiscan.R
import com.medithings.vesiscan.models.UrgencyLevel
class NotificationService private constructor(private val context: Context) {
companion object {
@Volatile
private var instance: NotificationService? = null
fun getInstance(context: Context): NotificationService =
instance ?: synchronized(this) {
instance ?: NotificationService(context.applicationContext).also { instance = it }
}
private const val CHANNEL_ID = "bladder_urgency"
private const val CHANNEL_NAME = "Bladder Monitoring"
}
private var lastNotifiedUrgency: String? = null
init {
createNotificationChannel()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Bladder level urgency notifications"
enableVibration(true)
}
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
fun sendUrgencyNotification(urgency: UrgencyLevel, volume: Int) {
val urgencyKey = urgency.rawStringValue
if (urgencyKey == "safe" || urgencyKey == lastNotifiedUrgency) return
lastNotifiedUrgency = urgencyKey
val (title, body) = when (urgency) {
UrgencyLevel.WARNING -> "Bladder Level Rising" to "Estimated $volume ml. Prepare for catheterization."
UrgencyLevel.URGENT -> "Catheterization Needed" to "Estimated $volume ml. Please catheterize now."
UrgencyLevel.SAFE -> return
}
val priority = if (urgency == UrgencyLevel.URGENT)
NotificationCompat.PRIORITY_MAX else NotificationCompat.PRIORITY_HIGH
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(body)
.setPriority(priority)
.setAutoCancel(true)
.build()
try {
NotificationManagerCompat.from(context).notify(urgencyKey.hashCode(), notification)
} catch (_: SecurityException) { }
}
fun cancelAll() {
lastNotifiedUrgency = null
NotificationManagerCompat.from(context).cancelAll()
}
}
@@ -0,0 +1,109 @@
/*
* PostureEventLogger — 자세/walking 알고리즘 검증용 CSV logger.
*
* 매 cycle 마다 분류 결과 + walking 상태 + 신호값 기록 +
* 사용자가 dev settings 의 annotation 버튼 누를 때 ground-truth marker 기록.
*
* 파일: Downloads/VesiScan_Posture/yyyy-MM-dd_HHmmss.csv
* 컬럼:
* timestamp_ms, type, posture, isWalking, pitch, wmean, wstd, accMag, gyroMag,
* alarm_event, annotation
*
* Row type:
* CYCLE — 매 cycle 의 자세/walking detector 결과
* ALARM — 알람 발동 (wakeup / walking)
* ANNOTATION — 사용자가 "지금 OOO" 표시 버튼 누름 (ground truth)
*
* 사용:
* 1. logSession(context) 한 번 호출 (PiezoMonitoring 진입 시) — 파일 생성
* 2. logCycle(...) 매 cycle 마다 호출
* 3. logAlarm(...) 알람 발동 시
* 4. annotate("WALK_START") 사용자가 버튼 누를 때
* 5. closeSession() 종료 시 (선택)
*
* 분석: CSV → Excel / python pandas → ANNOTATION 과 CYCLE 의 walking 전이 대조.
*/
package com.medithings.vesiscan.services
import android.content.Context
import android.os.Environment
import java.io.File
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object PostureEventLogger {
private val sessionFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
@Volatile private var sessionFile: File? = null
val currentFile: File? get() = sessionFile
private fun getDir(): File {
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"VesiScan_Posture"
)
if (!dir.exists()) dir.mkdirs()
return dir
}
/** 새 세션 파일 생성 + 헤더 작성. */
fun startSession(@Suppress("UNUSED_PARAMETER") context: Context): File {
val f = File(getDir(), "${sessionFmt.format(Date())}.csv")
try {
FileWriter(f, false).buffered().use { w ->
w.write("timestamp_ms,type,posture,isWalking,pitch,wmean,wstd,accMag,gyroMag,alarm_event,annotation")
w.newLine()
}
} catch (_: Exception) {}
sessionFile = f
return f
}
/** Cycle 의 자세 분류 결과 기록. */
fun logCycle(
nowMs: Long,
posture: String,
isWalking: Boolean,
pitch: Float,
wmean: Float,
wstd: Float,
accMag: Float,
gyroMag: Float,
) {
appendRow(
nowMs, "CYCLE", posture, isWalking.toString(),
pitch, wmean, wstd, accMag, gyroMag, "", ""
)
}
/** 알람 발동 (wakeup / walking) 기록. */
fun logAlarm(nowMs: Long, alarmEvent: String) {
appendRow(nowMs, "ALARM", "", "", 0f, 0f, 0f, 0f, 0f, alarmEvent, "")
}
/** 사용자 ground-truth annotation 기록 (예: "WALK_START"). */
fun annotate(label: String, nowMs: Long = System.currentTimeMillis()) {
appendRow(nowMs, "ANNOTATION", "", "", 0f, 0f, 0f, 0f, 0f, "", label)
}
fun closeSession() {
sessionFile = null
}
private fun appendRow(
ts: Long, type: String, posture: String, isWalking: String,
pitch: Float, wmean: Float, wstd: Float, accMag: Float, gyroMag: Float,
alarm: String, annotation: String
) {
val f = sessionFile ?: return
try {
FileWriter(f, true).buffered().use { w ->
w.write("$ts,$type,$posture,$isWalking,${"%.2f".format(pitch)},${"%.2f".format(wmean)},${"%.2f".format(wstd)},${"%.3f".format(accMag)},${"%.2f".format(gyroMag)},$alarm,$annotation")
w.newLine()
}
} catch (_: Exception) {}
}
}
@@ -0,0 +1,80 @@
/*
* PostureTuningPrefs — Posture / Walking detector 임계값 SharedPreferences 영속화.
*
* GreenZoneConstants 의 임계값들은 @Volatile var (메모리) 라 앱 재시작 시 default 리셋.
* 사용자가 dev slider 로 튜닝한 값을 유지하려면 prefs 에 저장 + 시작 시 로드.
*
* 사용:
* loadAll(context) — 앱/화면 시작 시 한 번 호출 → SharedPreferences 값 → GreenZoneConstants
* saveAll(context) — slider 변경 시 호출 → 현재 GreenZoneConstants → SharedPreferences
* resetAll(context) — 사용자가 reset 누름 → defaults 로 복원
*/
package com.medithings.vesiscan.services
import android.content.Context
import com.medithings.vesiscan.managers.GreenZoneConstants
object PostureTuningPrefs {
private const val PREF = "posture_tuning_prefs"
// Defaults — GreenZoneConstants 의 초기값과 일치 유지
private const val D_PITCH_LYING = 25f
private const val D_ACC_MIN = 0.8f
private const val D_ACC_MAX = 1.2f
private const val D_POLL_MS = 1000L
private const val D_WALK_PITCH = 45f
private const val D_WALK_START_WMEAN = 10f
private const val D_WALK_START_WSTD = 2f
private const val D_WALK_KEEP_WMEAN = 2f
private const val D_WALK_START_HOLD_MS = 6000L
private const val D_WALK_KEEP_HOLD_MS = 2000L
private const val D_WALK_WINDOW_MS = 1500L
fun loadAll(context: Context) {
val p = context.applicationContext.getSharedPreferences(PREF, Context.MODE_PRIVATE)
GreenZoneConstants.pitchLyingThreshold = p.getFloat("pitchLying", D_PITCH_LYING)
GreenZoneConstants.postureAccMagMin = p.getFloat("accMin", D_ACC_MIN)
GreenZoneConstants.postureAccMagMax = p.getFloat("accMax", D_ACC_MAX)
GreenZoneConstants.posturePollIntervalMs = p.getLong("pollMs", D_POLL_MS)
GreenZoneConstants.walkingMinPitchDeg = p.getFloat("wPitch", D_WALK_PITCH)
GreenZoneConstants.walkingStartWmean = p.getFloat("wStartWmean", D_WALK_START_WMEAN)
GreenZoneConstants.walkingStartWstd = p.getFloat("wStartWstd", D_WALK_START_WSTD)
GreenZoneConstants.walkingKeepWmean = p.getFloat("wKeepWmean", D_WALK_KEEP_WMEAN)
GreenZoneConstants.walkingStartHoldMs = p.getLong("wStartHoldMs", D_WALK_START_HOLD_MS)
GreenZoneConstants.walkingKeepHoldMs = p.getLong("wKeepHoldMs", D_WALK_KEEP_HOLD_MS)
GreenZoneConstants.walkingWindowMs = p.getLong("wWindowMs", D_WALK_WINDOW_MS)
}
fun saveAll(context: Context) {
context.applicationContext.getSharedPreferences(PREF, Context.MODE_PRIVATE).edit().apply {
putFloat("pitchLying", GreenZoneConstants.pitchLyingThreshold)
putFloat("accMin", GreenZoneConstants.postureAccMagMin)
putFloat("accMax", GreenZoneConstants.postureAccMagMax)
putLong("pollMs", GreenZoneConstants.posturePollIntervalMs)
putFloat("wPitch", GreenZoneConstants.walkingMinPitchDeg)
putFloat("wStartWmean", GreenZoneConstants.walkingStartWmean)
putFloat("wStartWstd", GreenZoneConstants.walkingStartWstd)
putFloat("wKeepWmean", GreenZoneConstants.walkingKeepWmean)
putLong("wStartHoldMs", GreenZoneConstants.walkingStartHoldMs)
putLong("wKeepHoldMs", GreenZoneConstants.walkingKeepHoldMs)
putLong("wWindowMs", GreenZoneConstants.walkingWindowMs)
apply()
}
}
fun resetAll(context: Context) {
GreenZoneConstants.pitchLyingThreshold = D_PITCH_LYING
GreenZoneConstants.postureAccMagMin = D_ACC_MIN
GreenZoneConstants.postureAccMagMax = D_ACC_MAX
GreenZoneConstants.posturePollIntervalMs = D_POLL_MS
GreenZoneConstants.walkingMinPitchDeg = D_WALK_PITCH
GreenZoneConstants.walkingStartWmean = D_WALK_START_WMEAN
GreenZoneConstants.walkingStartWstd = D_WALK_START_WSTD
GreenZoneConstants.walkingKeepWmean = D_WALK_KEEP_WMEAN
GreenZoneConstants.walkingStartHoldMs = D_WALK_START_HOLD_MS
GreenZoneConstants.walkingKeepHoldMs = D_WALK_KEEP_HOLD_MS
GreenZoneConstants.walkingWindowMs = D_WALK_WINDOW_MS
saveAll(context)
}
}
@@ -0,0 +1,94 @@
/*
* RawBleLogger — 모든 BLE notification 의 raw bytes 를 시간순으로 파일로 dump.
*
* 용도: IMU FSR / packet 구조 / loss 진단. JSON 가공 거치기 전 원본 그대로 저장.
*
* 파일: Downloads/VesiScan_RawBLE/yyyy-MM-dd_HHmmss_raw.log
* 포맷 (한 줄당 한 notification):
* HH:mm:ss.SSS dir=RX prefix=rim len=188 hex=72 69 6D 3A 00 0F ...
*
* 사용: startCapture() / stopCapture() 토글. 활성화 시 BleManager.processReceivedData
* 가 매 notification 마다 write() 호출.
*/
package com.medithings.vesiscan.services
import android.os.Environment
import java.io.File
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
object RawBleLogger {
private val sessionFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
private val capturing = AtomicBoolean(false)
@Volatile private var sessionFile: File? = null
@Volatile private var packetCount: Int = 0
val isCapturing: Boolean get() = capturing.get()
val currentFile: File? get() = sessionFile
val count: Int get() = packetCount
private fun getDir(): File {
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"VesiScan_RawBLE"
)
if (!dir.exists()) dir.mkdirs()
return dir
}
/** 새 파일 만들고 capture 시작. 이미 capturing 중이면 stop 후 새로 시작. */
fun startCapture(): File {
if (capturing.get()) stopCapture()
val f = File(getDir(), "${sessionFmt.format(Date())}_raw.log")
try {
FileWriter(f, false).buffered().use { w ->
w.write("# RawBleLogger session — started ${timeFmt.format(Date())}")
w.newLine()
w.write("# Format: HH:mm:ss.SSS dir=RX prefix=XXX len=N hex=AA BB ...")
w.newLine()
}
} catch (_: Exception) {}
sessionFile = f
packetCount = 0
capturing.set(true)
return f
}
fun stopCapture(): File? {
if (!capturing.get()) return sessionFile
val f = sessionFile
try {
if (f != null) {
FileWriter(f, true).buffered().use { w ->
w.write("# stopped ${timeFmt.format(Date())} — $packetCount packets")
w.newLine()
}
}
} catch (_: Exception) {}
capturing.set(false)
return f
}
/** BleManager 가 매 notification 받을 때마다 호출. capturing 아니면 즉시 return. */
fun write(data: ByteArray) {
if (!capturing.get()) return
val f = sessionFile ?: return
try {
val ts = timeFmt.format(Date())
val prefix = if (data.size >= 4)
String(data, 0, 4, Charsets.US_ASCII).replace(Regex("[^\\x20-\\x7E]"), "?")
else "?"
val hex = data.joinToString(" ") { "%02X".format(it) }
FileWriter(f, true).buffered().use { w ->
w.write("$ts dir=RX prefix=$prefix len=${data.size} hex=$hex")
w.newLine()
}
packetCount++
} catch (_: Exception) {}
}
}
@@ -0,0 +1,215 @@
package com.medithings.vesiscan.services
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.media.RingtoneManager
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.medithings.vesiscan.R
import java.util.Calendar
class ReminderService private constructor(private val context: Context) {
companion object {
@Volatile
private var instance: ReminderService? = null
fun getInstance(context: Context): ReminderService =
instance ?: synchronized(this) {
instance ?: ReminderService(context.applicationContext).also { instance = it }
}
private const val CHANNEL_ID = "catheter_reminder"
private const val NOTIFICATION_ID = 2001
private const val REQUEST_CODE = 3001
private const val PREFS_NAME = "reminder_prefs"
}
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
init {
createNotificationChannel()
}
// Settings
var isEnabled: Boolean
get() = prefs.getBoolean("enabled", false)
set(value) {
prefs.edit().putBoolean("enabled", value).apply()
if (value) scheduleNext() else cancelAll()
}
var intervalMinutes: Int
get() = prefs.getInt("interval_minutes", 120)
set(value) { prefs.edit().putInt("interval_minutes", value).apply() }
var startHour: Int
get() = prefs.getInt("start_hour", 8)
set(value) { prefs.edit().putInt("start_hour", value).apply() }
var endHour: Int
get() = prefs.getInt("end_hour", 22)
set(value) { prefs.edit().putInt("end_hour", value).apply() }
var useSound: Boolean
get() = prefs.getBoolean("use_sound", true)
set(value) { prefs.edit().putBoolean("use_sound", value).apply() }
var useVibration: Boolean
get() = prefs.getBoolean("use_vibration", true)
set(value) { prefs.edit().putBoolean("use_vibration", value).apply() }
var usePopup: Boolean
get() = prefs.getBoolean("use_popup", true)
set(value) { prefs.edit().putBoolean("use_popup", value).apply() }
var isFlexible: Boolean
get() = prefs.getBoolean("is_flexible", true)
set(value) { prefs.edit().putBoolean("is_flexible", value).apply() }
val permissionGranted: Boolean
get() = NotificationManagerCompat.from(context).areNotificationsEnabled()
fun scheduleNext() {
if (!isEnabled) return
val now = Calendar.getInstance()
val nextTime = Calendar.getInstance().apply {
timeInMillis = now.timeInMillis + intervalMinutes * 60 * 1000L
}
// 시간대 체크: endHour 이후면 다음날 startHour로
if (nextTime.get(Calendar.HOUR_OF_DAY) >= endHour ||
nextTime.get(Calendar.HOUR_OF_DAY) < startHour) {
nextTime.apply {
if (get(Calendar.HOUR_OF_DAY) >= endHour) {
add(Calendar.DAY_OF_YEAR, 1)
}
set(Calendar.HOUR_OF_DAY, startHour)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
}
}
val intent = Intent(context, ReminderReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context, REQUEST_CODE, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, nextTime.timeInMillis, pendingIntent
)
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, nextTime.timeInMillis, pendingIntent
)
}
} else {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, nextTime.timeInMillis, pendingIntent
)
}
} catch (_: SecurityException) {
// Fallback to inexact alarm
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, nextTime.timeInMillis, pendingIntent
)
}
}
fun reschedule() {
if (isEnabled) {
cancelAll()
scheduleNext()
}
}
fun cancelAll() {
val intent = Intent(context, ReminderReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context, REQUEST_CODE, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
}
private val reminderMessageIds = listOf(
R.string.reminder_msg_1,
R.string.reminder_msg_2,
R.string.reminder_msg_3,
R.string.reminder_msg_4,
R.string.reminder_msg_5,
)
fun showNotification() {
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(context.getString(R.string.notification_title))
.setContentText(context.getString(reminderMessageIds.random()))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
if (useSound) {
builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
} else {
builder.setSilent(true)
}
if (!usePopup) {
builder.setPriority(NotificationCompat.PRIORITY_LOW)
}
try {
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build())
} catch (_: SecurityException) { }
if (useVibration) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vm.defaultVibrator.vibrate(VibrationEffect.createOneShot(500, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
vibrator.vibrate(VibrationEffect.createOneShot(500, VibrationEffect.DEFAULT_AMPLITUDE))
}
} catch (_: Exception) { }
}
// Chain: schedule next alarm
scheduleNext()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID, context.getString(R.string.notification_channel_name), NotificationManager.IMPORTANCE_HIGH
).apply {
description = context.getString(R.string.notification_channel_desc)
enableVibration(true)
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel)
}
}
}
class ReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
ReminderService.getInstance(context).showNotification()
}
}
@@ -0,0 +1,76 @@
package com.medithings.vesiscan.services
import com.medithings.vesiscan.ble.PiezoChannelData
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.text.SimpleDateFormat
import java.util.Locale
/**
* Holds the **last** Single Scan summary line so it can be inspected and
* copied to the clipboard from the developer-mode log dialog. Each new
* Single Scan replaces the previous entry. Format and clipboard interface
* mirror the xbench WallCompareViewModel mbb log so the same downstream
* parsers / spreadsheets work unchanged.
*/
object ScanLogStore {
private val tsFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
private val _scanLog = MutableStateFlow<List<String>>(emptyList())
val scanLog: StateFlow<List<String>> = _scanLog.asStateFlow()
fun clear() {
_scanLog.value = emptyList()
}
/**
* Replaces the stored line with the latest Single Scan summary. If the
* latest successful measurement produced anterior/posterior wall indices,
* a CSV block is appended so the walls can be pasted into Excel / numpy.
* `successfulWalls[ch] = (ant, post)` or `null` if that channel failed.
*/
fun appendScanSummary(
attemptCount: Int,
spotVolumes: List<Double>,
trimmedMeanMl: Double?,
successfulWalls: List<Pair<Int, Int>?>? = null,
successfulChannels: List<PiezoChannelData>? = null,
antOffsetMm: Int = 0,
) {
val ts = tsFmt.format(System.currentTimeMillis())
val sb = StringBuilder()
sb.append("[$ts] SUMMARY attempts=$attemptCount results=${spotVolumes.size}")
if (trimmedMeanMl != null) {
sb.append(" trimmedMean=${"%.1f".format(trimmedMeanMl)}ml")
if (spotVolumes.isNotEmpty()) {
sb.append(" values=[${spotVolumes.joinToString(", ") { "%.1f".format(it) }}]")
}
} else {
sb.append(" ✗ insufficient results")
}
if (antOffsetMm != 0) {
sb.append(" antOffset=-${antOffsetMm}mm")
}
if (successfulWalls != null) {
sb.append("\n WALLS (CSV — ch,ant,post,urine):")
successfulWalls.forEachIndexed { ch, w ->
if (w != null) {
sb.append("\n CH$ch,${w.first},${w.second},${w.second - w.first}")
} else {
sb.append("\n CH$ch,,,")
}
}
}
if (successfulChannels != null) {
sb.append("\n DATA (CSV — one row per channel, comma-separated samples):")
successfulChannels.sortedBy { it.channel }.forEach { ch ->
val s = ch.buffer
val csv = if (s.isEmpty()) "—"
else s.joinToString(",") { (it.toInt() and 0xFFFF).toString() }
sb.append("\n CH${ch.channel}[${s.size}]: $csv")
}
}
_scanLog.value = listOf(sb.toString())
}
}
@@ -0,0 +1,122 @@
package com.medithings.vesiscan.services
import android.content.Context
import android.content.SharedPreferences
import com.medithings.vesiscan.models.*
import com.google.gson.Gson
class UserStorage private constructor(context: Context) {
companion object {
@Volatile
private var instance: UserStorage? = null
fun getInstance(context: Context): UserStorage =
instance ?: synchronized(this) {
instance ?: UserStorage(context.applicationContext).also { instance = it }
}
}
private val prefs: SharedPreferences = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
private val gson = Gson()
init {
migratePinToKeychain()
}
var user: User
get() {
val json = prefs.getString("user_data", null) ?: return User()
return try { gson.fromJson(json, User::class.java) } catch (_: Exception) { User() }
}
set(value) { prefs.edit().putString("user_data", gson.toJson(value)).apply() }
var isRegistered: Boolean
get() = prefs.getBoolean("is_registered", false)
set(value) { prefs.edit().putBoolean("is_registered", value).apply() }
var pin: String
get() {
KeychainService.load("user_pin")?.let { return it }
return prefs.getString("user_pin", "") ?: ""
}
set(value) {
KeychainService.save("user_pin", value)
prefs.edit().remove("user_pin").apply()
}
var hasPin: Boolean
get() = prefs.getBoolean("has_pin", false)
set(value) { prefs.edit().putBoolean("has_pin", value).apply() }
var sensorMode: SensorMode
get() {
val raw = prefs.getString("sensor_mode", null) ?: return SensorMode.PIEZO
return try { SensorMode.valueOf(raw) } catch (_: Exception) { SensorMode.PIEZO }
}
set(value) { prefs.edit().putString("sensor_mode", value.name).apply() }
var lastDeviceAddress: String?
get() = prefs.getString("last_device_address", null)
set(value) { prefs.edit().putString("last_device_address", value).apply() }
var lastDeviceName: String?
get() = prefs.getString("last_device_name", null)
set(value) { prefs.edit().putString("last_device_name", value).apply() }
var catheterizationLevel: Int
get() {
val v = prefs.getInt("catheterization_level", 0)
return if (v > 0) v else 8
}
set(value) { prefs.edit().putInt("catheterization_level", value).apply() }
var bladderLevel: Int
get() = prefs.getInt("bladder_level", 0)
set(value) { prefs.edit().putInt("bladder_level", value).apply() }
var catheterCount: Int
get() = prefs.getInt("catheter_count", 15)
set(value) { prefs.edit().putInt("catheter_count", value).apply() }
var hasCompletedOnboarding: Boolean
get() = prefs.getBoolean("has_completed_onboarding", false)
set(value) { prefs.edit().putBoolean("has_completed_onboarding", value).apply() }
var hasConsented: Boolean
get() = prefs.getBoolean("has_consented", false)
set(value) { prefs.edit().putBoolean("has_consented", value).apply() }
var consentTimestamp: Long
get() = prefs.getLong("consent_timestamp", 0L)
set(value) { prefs.edit().putLong("consent_timestamp", value).apply() }
var consentPersonalInfo: Boolean
get() = prefs.getBoolean("consent_personal_info", false)
set(value) { prefs.edit().putBoolean("consent_personal_info", value).apply() }
var consentMedicalInfo: Boolean
get() = prefs.getBoolean("consent_medical_info", false)
set(value) { prefs.edit().putBoolean("consent_medical_info", value).apply() }
var consentDataCollection: Boolean
get() = prefs.getBoolean("consent_data_collection", false)
set(value) { prefs.edit().putBoolean("consent_data_collection", value).apply() }
var piezoSettings: PiezoSettings
get() {
val json = prefs.getString("piezo_settings", null) ?: return PiezoSettings()
return try { gson.fromJson(json, PiezoSettings::class.java) } catch (_: Exception) { PiezoSettings() }
}
set(value) { prefs.edit().putString("piezo_settings", gson.toJson(value)).apply() }
private fun migratePinToKeychain() {
val existingPin = prefs.getString("user_pin", null)
if (existingPin.isNullOrEmpty()) return
val keychainPin = KeychainService.load("user_pin")
if (!keychainPin.isNullOrEmpty()) {
prefs.edit().remove("user_pin").apply()
return
}
KeychainService.save("user_pin", existingPin)
prefs.edit().remove("user_pin").apply()
}
}
@@ -0,0 +1,42 @@
/*
* WakeupAlarmPrefs — 기상 알람 설정 persistence (SharedPreferences).
*
* 사용자가 "설정 탭" 에서 toggle on/off → 일어남 감지 시 빨간 팝업.
* LYING 유지 시간 임계 (default 3 min) 도 함께.
*/
package com.medithings.vesiscan.services
import android.content.Context
import android.content.SharedPreferences
object WakeupAlarmPrefs {
private const val PREF_NAME = "wakeup_alarm_prefs"
private const val KEY_ENABLED = "enabled"
private const val KEY_LYING_DURATION_SEC = "lying_duration_sec"
private var prefs: SharedPreferences? = null
private fun ensure(context: Context): SharedPreferences {
val p = prefs
if (p != null) return p
val newP = context.applicationContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
prefs = newP
return newP
}
fun isEnabled(context: Context): Boolean =
ensure(context).getBoolean(KEY_ENABLED, false)
fun setEnabled(context: Context, enabled: Boolean) {
ensure(context).edit().putBoolean(KEY_ENABLED, enabled).apply()
}
/** 일어남으로 인정하기 위한 직전 LYING 유지 시간 (초). default 180 = 3분. */
fun lyingDurationSec(context: Context): Int =
ensure(context).getInt(KEY_LYING_DURATION_SEC, 180)
fun setLyingDurationSec(context: Context, sec: Int) {
ensure(context).edit().putInt(KEY_LYING_DURATION_SEC, sec).apply()
}
}
@@ -0,0 +1,237 @@
package com.medithings.vesiscan.services.labdb
import android.content.Context
import android.os.Build
import android.util.Log
import com.medithings.vesiscan.ble.BleManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
sealed class LabdbException(message: String) : IOException(message)
class PendingApprovalException : LabdbException("Device is awaiting administrator approval")
class ReregisterRequiredException(message: String) : LabdbException(message)
class RateLimitedException : LabdbException("Rate limit exceeded (10 req/min)")
class LabdbApiError(val httpStatus: Int, val code: String, message: String) : LabdbException("$code ($httpStatus): $message")
class LabdbNetworkError(cause: Throwable) : LabdbException("Network error: ${cause.message}")
data class RegisterResult(val deviceId: String, val apiKey: String, val status: String, val message: String)
data class StatusResult(val httpStatus: Int, val status: String, val deviceName: String?, val sessionCount: Int?)
data class UploadResult(
val ok: Boolean,
val sessionId: String,
val created: Boolean,
val totalRecords: Int,
val inserted: Int,
val duplicates: Int,
val errors: Int,
)
/**
* https://labdb.medithings.net REST 클라이언트.
*
* 사용 순서:
* 1) register(context, appVersion) — 앱 첫 임상 모드 진입 시 1회
* 2) checkStatus() — 임상 화면 진입 시 매번
* 3) uploadJson(payload) — End Session 후 measurement.json 업로드
*
* 모든 메서드는 suspend, IO 디스패처에서 실행.
*/
object LabdbClient {
private const val TAG = "LabdbClient"
private const val BASE = "https://labdb.medithings.net/api/v1"
private const val APP_NAME = "VesiScan"
private val JSON = "application/json; charset=utf-8".toMediaType()
private val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
}
/** 디바이스 등록 — 앱 생애 1회. 응답에 status='pending'이면 admin 승인 대기. */
suspend fun register(context: Context, appVersion: String): RegisterResult = withContext(Dispatchers.IO) {
val ble = BleManager.getInstance(context)
val body = JSONObject().apply {
put("appName", APP_NAME)
put("deviceName", "${Build.MANUFACTURER} ${Build.MODEL}")
ble.firmwareVersion.value.takeIf { it.isNotEmpty() }?.let { put("fwVersion", it) }
ble.hardwareVersion.value.takeIf { it.isNotEmpty() }?.let { put("hwNumber", it) }
ble.serialNumber.value.takeIf { it.isNotEmpty() }?.let { put("serialNumber", it) }
put("appVersion", appVersion)
}
sanitizeForJsonb(body)
Log.d(TAG, ">> POST /devices/register payload=$body")
val req = Request.Builder()
.url("$BASE/devices/register")
.post(body.toString().toRequestBody(JSON))
.build()
val (code, json) = exec(req)
when (code) {
200, 201 -> {
val deviceId = json.optString("deviceId")
val apiKey = json.optString("apiKey")
val status = json.optString("status", "pending")
val message = json.optString("message", "")
if (apiKey.isBlank()) throw LabdbApiError(code, "INVALID_RESPONSE", "register response missing apiKey")
LabdbCredentials.apiKey = apiKey
LabdbCredentials.deviceId = deviceId
LabdbCredentials.lastStatus = status
RegisterResult(deviceId, apiKey, status, message)
}
else -> throw apiError(code, json)
}
}
/** 디바이스 상태 확인. apiKey 무효 시 자격 자동 clear. */
suspend fun checkStatus(): StatusResult = withContext(Dispatchers.IO) {
val key = LabdbCredentials.apiKey
?: return@withContext StatusResult(0, "not_registered", null, null)
val req = Request.Builder()
.url("$BASE/devices/status")
.header("X-API-Key", key)
.get()
.build()
val (code, json) = exec(req)
when (code) {
200 -> {
val status = json.optString("status", "unknown")
LabdbCredentials.lastStatus = status
StatusResult(
httpStatus = 200,
status = status,
deviceName = json.optString("deviceName").ifBlank { null },
sessionCount = if (json.has("sessionCount")) json.optInt("sessionCount") else null,
)
}
401, 404 -> {
LabdbCredentials.clear()
val err = json.optJSONObject("error")
val errCode = err?.optString("code") ?: if (code == 401) "INVALID_API_KEY" else "DEVICE_DELETED"
throw ReregisterRequiredException("$errCode: server requires re-registration")
}
else -> throw apiError(code, json)
}
}
/** /upload/json — measurement.json payload 한 번에 전송. testId+rowIndex 기반 idempotent. */
suspend fun uploadJson(payload: JSONObject): UploadResult = withContext(Dispatchers.IO) {
val key = LabdbCredentials.apiKey
?: throw ReregisterRequiredException("No apiKey — register first")
// PostgreSQL JSONB rejects \u0000 — strip NULL/control chars from payload
sanitizeForJsonb(payload)
val req = Request.Builder()
.url("$BASE/upload/json")
.header("X-API-Key", key)
.post(payload.toString().toRequestBody(JSON))
.build()
val (code, json) = exec(req)
when (code) {
200, 201 -> UploadResult(
ok = json.optBoolean("ok", true),
sessionId = json.optString("sessionId"),
created = json.optBoolean("created", code == 201),
totalRecords = json.optInt("totalRecords"),
inserted = json.optInt("inserted"),
duplicates = json.optInt("duplicates"),
errors = json.optInt("errors"),
)
401 -> { LabdbCredentials.clear(); throw ReregisterRequiredException("INVALID_API_KEY") }
403 -> throw PendingApprovalException()
404 -> { LabdbCredentials.clear(); throw ReregisterRequiredException("DEVICE_DELETED") }
429 -> throw RateLimitedException()
else -> throw apiError(code, json)
}
}
/**
* 세션 삭제 — DELETE /api/v1/sessions/{testId}. Cascade로 모든 records 함께 제거.
* 204(No Content) 또는 200/404(이미 없음) = 성공. 401/403/등은 throw.
*/
suspend fun deleteSession(testId: String): Boolean = withContext(Dispatchers.IO) {
val key = LabdbCredentials.apiKey
?: throw ReregisterRequiredException("No apiKey — register first")
Log.d(TAG, ">> DELETE /sessions/$testId")
val req = Request.Builder()
.url("$BASE/sessions/$testId")
.header("X-API-Key", key)
.delete()
.build()
val (code, json) = exec(req)
when (code) {
200, 204, 404 -> true // 404 = 이미 없음, 멱등 처리
401 -> { LabdbCredentials.clear(); throw ReregisterRequiredException("INVALID_API_KEY") }
403 -> throw PendingApprovalException()
429 -> throw RateLimitedException()
else -> throw apiError(code, json)
}
}
/**
* Recursively strip NULL (0x00) and other C0 control chars from every string value.
* PostgreSQL JSONB rejects escapes with "unsupported Unicode escape sequence"
* (HTTP 500). BLE responses occasionally carry trailing 0x00 padding that survives
* String(US_ASCII) decoding + .trim().
*/
internal fun sanitizeForJsonb(root: JSONObject) {
val keys = root.keys().asSequence().toList()
for (k in keys) {
when (val v = root.opt(k)) {
is String -> root.put(k, stripControl(v))
is JSONObject -> sanitizeForJsonb(v)
is JSONArray -> sanitizeArray(v)
}
}
}
private fun sanitizeArray(arr: JSONArray) {
for (i in 0 until arr.length()) {
when (val v = arr.opt(i)) {
is String -> arr.put(i, stripControl(v))
is JSONObject -> sanitizeForJsonb(v)
is JSONArray -> sanitizeArray(v)
}
}
}
private fun stripControl(s: String): String {
if (s.none { it.code == 0 || it.code == 127 || (it.code in 1..31 && it.code != 9 && it.code != 10 && it.code != 13) }) return s
val sb = StringBuilder(s.length)
for (c in s) {
val code = c.code
if (code == 0 || code == 127 || (code in 1..31 && code != 9 && code != 10 && code != 13)) continue
sb.append(c)
}
return sb.toString()
}
private fun apiError(code: Int, json: JSONObject): LabdbApiError {
val err = json.optJSONObject("error")
val errCode = err?.optString("code") ?: "HTTP_$code"
val msg = err?.optString("message") ?: json.toString().take(200)
return LabdbApiError(code, errCode, msg)
}
private fun exec(req: Request): Pair<Int, JSONObject> {
return try {
client.newCall(req).execute().use { res ->
val raw = res.body?.string().orEmpty()
Log.d(TAG, "<< ${req.method} ${req.url.encodedPath} → HTTP ${res.code} body=${raw.take(500)}")
val obj = if (raw.isBlank()) JSONObject() else runCatching { JSONObject(raw) }.getOrElse { JSONObject() }
res.code to obj
}
} catch (t: IOException) {
Log.w(TAG, "exec failed: ${req.method} ${req.url}", t)
throw LabdbNetworkError(t)
}
}
}
@@ -0,0 +1,46 @@
package com.medithings.vesiscan.services.labdb
import com.medithings.vesiscan.services.KeychainService
/**
* labdb (https://labdb.medithings.net) 자격 증명 저장소.
*
* apiKey/deviceId는 KeychainService(EncryptedSharedPreferences)에 안전 저장.
* 등록은 앱 첫 임상 모드 진입 시 1회만 — 재등록 시 pending device 누적되므로
* 401 INVALID_API_KEY / 404 DEVICE_DELETED 응답을 받았을 때만 clear() 후 재등록.
*/
object LabdbCredentials {
private const val KEY_API_KEY = "labdb.apiKey"
private const val KEY_DEVICE_ID = "labdb.deviceId"
private const val KEY_LAST_STATUS = "labdb.lastStatus"
var apiKey: String?
get() = KeychainService.load(KEY_API_KEY)
set(value) {
if (value == null) KeychainService.delete(KEY_API_KEY)
else KeychainService.save(KEY_API_KEY, value)
}
var deviceId: String?
get() = KeychainService.load(KEY_DEVICE_ID)
set(value) {
if (value == null) KeychainService.delete(KEY_DEVICE_ID)
else KeychainService.save(KEY_DEVICE_ID, value)
}
/** 마지막으로 GET /devices/status 응답에서 받은 상태 ("active"/"pending"/"revoked") */
var lastStatus: String?
get() = KeychainService.load(KEY_LAST_STATUS)
set(value) {
if (value == null) KeychainService.delete(KEY_LAST_STATUS)
else KeychainService.save(KEY_LAST_STATUS, value)
}
val isRegistered: Boolean get() = !apiKey.isNullOrBlank()
fun clear() {
apiKey = null
deviceId = null
lastStatus = null
}
}
@@ -0,0 +1,235 @@
package com.medithings.vesiscan.services.labdb
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* 측정 폴더(measurement.json 포함) → labdb /upload/json 자동 업로드.
*
* 정책:
* - End Session 시 ClinicalSessionStore가 uploadAsync(folder)를 호출
* - 성공: folder/labdb_upload.json (응답 본문)
* - 실패: folder/labdb_upload_error.json (에러 본문) — 폴더는 그대로 유지되어 수동 재시도 가능
*
* dataType = "001" (내부 임상 R&D — 외부 일반 VesiScan "000"과 구분).
* measurement.json의 meta.data_type을 우선 사용, 없으면 DEFAULT_DATA_TYPE fallback.
*
* testId:
* - 폴더명 그대로 사용 (사용자 결정). 단 ^[A-Za-z0-9_-]{1,40}$ 제한 때문에
* 40자 초과 시 앞 31자 + "_" + 8자 SHA-1 prefix로 축약(unique 유지).
*/
object LabdbUploader {
private const val TAG = "LabdbUploader"
private const val DEFAULT_DATA_TYPE = "001"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val isoFmt: SimpleDateFormat
get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US)
/** 비동기 업로드 — fire-and-forget. 결과는 폴더 내 마커 파일로 확인. */
fun uploadAsync(context: Context, folder: File, onResult: ((Boolean, String) -> Unit)? = null) {
scope.launch {
val ok = runCatching { uploadBlocking(folder) }
.onFailure { Log.w(TAG, "upload failed: ${folder.name}", it) }
.getOrElse { e ->
writeError(folder, e)
onResult?.invoke(false, e.message ?: e.javaClass.simpleName)
return@launch
}
onResult?.invoke(true, "uploaded: ${ok.inserted}/${ok.totalRecords} (dup=${ok.duplicates})")
}
}
/** 동기 업로드 — 재시도 버튼/자동 호출 양쪽 사용. */
suspend fun uploadBlocking(folder: File): UploadResult {
// 새 파일명 (measurement_<folderName>.json) 우선, 구 버전 (measurement.json) fallback
val measurementFile = File(folder, "measurement_${folder.name}.json").takeIf { it.exists() }
?: File(folder, "measurement.json").takeIf { it.exists() }
?: folder.listFiles()?.firstOrNull { it.name.startsWith("measurement_") && it.name.endsWith(".json") }
?: throw IllegalStateException("measurement json not found in ${folder.name}")
val measurement = JSONObject(measurementFile.readText())
val payload = buildPayload(folder.name, measurement)
val result = LabdbClient.uploadJson(payload)
writeSuccess(folder, result)
return result
}
fun isUploaded(folder: File): Boolean = File(folder, "labdb_upload.json").exists()
fun lastError(folder: File): String? {
val f = File(folder, "labdb_upload_error.json")
return if (f.exists()) runCatching { JSONObject(f.readText()).optString("message") }.getOrNull() else null
}
/**
* 폴더에 해당하는 labdb 서버 세션 삭제.
* labdb_upload.json에 sessionId가 있으면 그것을 사용, 없으면 folderName → sanitizeTestId 변환.
* 미등록/미업로드 폴더의 testId도 그대로 호출 → labdb가 404로 응답 → 성공 처리.
*/
suspend fun deleteSessionForFolder(folder: File): Boolean {
val uploadMarker = File(folder, "labdb_upload.json")
val testId = if (uploadMarker.exists()) {
runCatching { JSONObject(uploadMarker.readText()).optString("sessionId") }
.getOrNull()?.takeIf { it.isNotBlank() } ?: sanitizeTestId(folder.name)
} else {
sanitizeTestId(folder.name)
}
return if (testId.isNotBlank()) LabdbClient.deleteSession(testId) else true
}
/** measurement.json → labdb payload. 각 cycle = 1 record. */
private fun buildPayload(folderName: String, measurement: JSONObject): JSONObject {
val meta = measurement.optJSONObject("meta") ?: JSONObject()
val cycles = measurement.optJSONArray("cycles") ?: JSONArray()
val testId = sanitizeTestId(folderName)
val dataType = meta.optString("data_type").ifBlank { DEFAULT_DATA_TYPE }
val records = JSONArray()
for (i in 0 until cycles.length()) {
val cycle = cycles.optJSONObject(i) ?: continue
records.put(cycleToRecord(cycle, meta))
}
val params = JSONObject().apply {
// 측정 컨텍스트 — admin 콘솔에서 한눈에 보이도록
put("posture", meta.optString("posture"))
put("step", meta.optString("step"))
put("is_alignment", meta.optBoolean("is_alignment"))
put("examiner", meta.optString("examiner"))
meta.optString("subject_type").takeIf { it.isNotBlank() }?.let { put("subject_type", it) }
meta.optString("subject").takeIf { it.isNotBlank() }?.let { put("subject", it) }
if (meta.has("true_volume_ml")) put("true_volume_ml", meta.optDouble("true_volume_ml"))
if (meta.has("abdomen_thickness_mm")) put("abdomen_thickness_mm", meta.optDouble("abdomen_thickness_mm"))
meta.optString("firmware_version").takeIf { it.isNotBlank() }?.let { put("firmware_version", it) }
meta.optString("hardware_version").takeIf { it.isNotBlank() }?.let { put("hardware_version", it) }
meta.optString("serial_number").takeIf { it.isNotBlank() }?.let { put("serial_number", it) }
put("app_version", meta.optString("app_version_name"))
put("captured_cycles", cycles.length())
}
return JSONObject().apply {
put("testId", testId)
put("dataType", dataType)
// 서버는 sessionName을 export 파일명의 base로 사용 (날짜 포함이면 중복 안 붙임).
// 풀 폴더명을 그대로 보내야 분석 PC 다운로드 파일명과 앱 측 파일명이 일치.
put("sessionName", folderName)
put("memo", meta.optString("notes"))
meta.optLong("ended_at", 0L).takeIf { it > 0 }?.let {
put("savedAt", isoFmt.format(Date(it)))
}
put("params", params)
put("recordCount", records.length())
put("records", records)
}
}
private fun cycleToRecord(cycle: JSONObject, meta: JSONObject): JSONObject {
val rowIndex = cycle.optInt("cycle")
val tsMs = cycle.optLong("timestamp_ms")
val piezo = cycle.optJSONObject("piezo") ?: JSONObject()
val imuArr = cycle.optJSONArray("imu") ?: JSONArray()
// labdb 표준 sensor.imu는 단일 샘플. 우리는 cycle당 다수 샘플(FIFO 순)이라
// 마지막(=가장 최근, piezo 캡처 직전) 샘플을 sensor.imu에 넣고
// 전체를 sensor.imu_samples에 free field로 보존.
val latestImu = if (imuArr.length() > 0) imuArr.optJSONObject(imuArr.length() - 1) else null
val sensor = JSONObject().apply {
latestImu?.let {
put("imu", JSONObject().apply {
put("ax", it.optDouble("ax"))
put("ay", it.optDouble("ay"))
put("az", it.optDouble("az"))
put("gx", it.optDouble("gx"))
put("gy", it.optDouble("gy"))
put("gz", it.optDouble("gz"))
})
}
put("imu_samples", imuArr)
put("imu_sample_count", imuArr.length())
}
val channelsArr = JSONArray()
for (ch in 0..5) {
val data = piezo.optJSONArray("CH$ch") ?: continue
var peak = 0
var peakIdx = 0
for (i in 0 until data.length()) {
val v = data.optInt(i)
if (v > peak) { peak = v; peakIdx = i }
}
channelsArr.put(JSONObject().apply {
put("ch", ch)
put("peak", peak)
put("peakIdx", peakIdx)
put("data", data)
})
}
return JSONObject().apply {
put("rowIndex", rowIndex)
put("datetime", isoFmt.format(Date(tsMs)))
put("commandType", "MTB")
put("sensor", sensor)
put("channels", channelsArr)
// Alignment metadata (alignment session 의 cycle 일 때만 채워짐) — labdb 서버가
// raw.json export 시 함께 내려줘 분석 PC 에서 phase 별 frame 분석 가능.
if (cycle.has("align_phase")) {
put("align_phase", cycle.optString("align_phase"))
put("align_score", cycle.optInt("align_score"))
cycle.optString("align_hint").takeIf { it.isNotBlank() }?.let { put("align_hint", it) }
cycle.optString("align_icon").takeIf { it.isNotBlank() }?.let { put("align_icon", it) }
cycle.optJSONArray("align_detected")?.let { put("align_detected", it) }
cycle.optJSONArray("align_urine_lens")?.let { put("align_urine_lens", it) }
}
}
}
/** ^[A-Za-z0-9_-]{1,40}$ 강제. 40자 초과 시 앞 31자 + "_" + 8자 SHA-1 prefix. */
private fun sanitizeTestId(raw: String): String {
// - 와 _ 이외 특수문자 제거
val cleaned = raw.replace(Regex("[^A-Za-z0-9_-]"), "_")
if (cleaned.length <= 40) return cleaned
val hash = MessageDigest.getInstance("SHA-1").digest(cleaned.toByteArray())
.joinToString("") { "%02x".format(it) }.take(8)
return cleaned.take(31) + "_" + hash
}
private fun writeSuccess(folder: File, r: UploadResult) {
val body = JSONObject().apply {
put("ok", r.ok)
put("sessionId", r.sessionId)
put("created", r.created)
put("totalRecords", r.totalRecords)
put("inserted", r.inserted)
put("duplicates", r.duplicates)
put("errors", r.errors)
put("uploadedAt", isoFmt.format(Date()))
}
File(folder, "labdb_upload.json").writeText(body.toString(2))
// 이전 실패 마커가 있었다면 정리
File(folder, "labdb_upload_error.json").delete()
}
private fun writeError(folder: File, t: Throwable) {
val body = JSONObject().apply {
put("error", t.javaClass.simpleName)
put("message", t.message ?: "")
if (t is LabdbApiError) {
put("httpStatus", t.httpStatus)
put("code", t.code)
}
put("failedAt", isoFmt.format(Date()))
}
File(folder, "labdb_upload_error.json").writeText(body.toString(2))
}
}