feat: PlacementGuideView + MeasurementLogService + navigation flow

PlacementGuideView.kt:
- 6-sensor Canvas layout (CH0-CH3 vertical + CH4/CH5 lateral wings)
- Per-channel UrinAI detection (green/red color coding)
- Direction guidance (up/down/left/right)
- Check Position → maa → detect → display
- Start Measurement when 4+ channels detected

MeasurementLogService.kt:
- JSON Lines format, daily rotation
- Events: measurement, placement, ble_connected/disconnected, error
- 90-day auto-purge
- App internal storage: vesiscan_logs/YYYY-MM-DD.jsonl

AppState.kt + MainActivity.kt:
- PLACEMENT_GUIDE screen added to navigation
- Flow: personalization → placementGuide → piezoMonitoring

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dwjang
2026-04-21 16:43:02 +09:00
parent c136d64665
commit 549a2c722e
4 changed files with 735 additions and 1 deletions
@@ -0,0 +1,206 @@
package com.example.medilightv2android.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(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)
init {
logDir = File(context.filesDir, "vesiscan_logs")
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", getAppVersion())
}
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.
*/
fun logPlacement(
channelDetected: List<Boolean>,
direction: String
) {
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)
}
appendLog(entry)
}
/**
* 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 --
private fun appendLog(entry: JSONObject) {
try {
val line = entry.toString() + "\n"
val fileName = dateFormatter.format(Date()) + ".jsonl"
val file = File(logDir, fileName)
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 90) */
fun purgeOldLogs(keepDays: Int = 90) {
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()
}
}
}
private fun getAppVersion(): String {
return try {
val context = logDir.parentFile // filesDir parent hack — not ideal
"1.0" // Fallback; ideally inject from BuildConfig
} catch (e: Exception) {
"unknown"
}
}
}
/** 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
)