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:
@@ -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 | ||||