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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 14:30:43 +09:00
parent 55d55225ab
commit bcbc7b440c
159 changed files with 2865 additions and 717 deletions
@@ -0,0 +1,237 @@
package com.medithings.vesiscan.services.labdb
import android.content.Context
import android.os.Build
import android.util.Log
import com.medithings.vesiscan.ble.BleManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
sealed class LabdbException(message: String) : IOException(message)
class PendingApprovalException : LabdbException("Device is awaiting administrator approval")
class ReregisterRequiredException(message: String) : LabdbException(message)
class RateLimitedException : LabdbException("Rate limit exceeded (10 req/min)")
class LabdbApiError(val httpStatus: Int, val code: String, message: String) : LabdbException("$code ($httpStatus): $message")
class LabdbNetworkError(cause: Throwable) : LabdbException("Network error: ${cause.message}")
data class RegisterResult(val deviceId: String, val apiKey: String, val status: String, val message: String)
data class StatusResult(val httpStatus: Int, val status: String, val deviceName: String?, val sessionCount: Int?)
data class UploadResult(
val ok: Boolean,
val sessionId: String,
val created: Boolean,
val totalRecords: Int,
val inserted: Int,
val duplicates: Int,
val errors: Int,
)
/**
* https://labdb.medithings.net REST 클라이언트.
*
* 사용 순서:
* 1) register(context, appVersion) — 앱 첫 임상 모드 진입 시 1회
* 2) checkStatus() — 임상 화면 진입 시 매번
* 3) uploadJson(payload) — End Session 후 measurement.json 업로드
*
* 모든 메서드는 suspend, IO 디스패처에서 실행.
*/
object LabdbClient {
private const val TAG = "LabdbClient"
private const val BASE = "https://labdb.medithings.net/api/v1"
private const val APP_NAME = "VesiScan"
private val JSON = "application/json; charset=utf-8".toMediaType()
private val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
}
/** 디바이스 등록 — 앱 생애 1회. 응답에 status='pending'이면 admin 승인 대기. */
suspend fun register(context: Context, appVersion: String): RegisterResult = withContext(Dispatchers.IO) {
val ble = BleManager.getInstance(context)
val body = JSONObject().apply {
put("appName", APP_NAME)
put("deviceName", "${Build.MANUFACTURER} ${Build.MODEL}")
ble.firmwareVersion.value.takeIf { it.isNotEmpty() }?.let { put("fwVersion", it) }
ble.hardwareVersion.value.takeIf { it.isNotEmpty() }?.let { put("hwNumber", it) }
ble.serialNumber.value.takeIf { it.isNotEmpty() }?.let { put("serialNumber", it) }
put("appVersion", appVersion)
}
sanitizeForJsonb(body)
Log.d(TAG, ">> POST /devices/register payload=$body")
val req = Request.Builder()
.url("$BASE/devices/register")
.post(body.toString().toRequestBody(JSON))
.build()
val (code, json) = exec(req)
when (code) {
200, 201 -> {
val deviceId = json.optString("deviceId")
val apiKey = json.optString("apiKey")
val status = json.optString("status", "pending")
val message = json.optString("message", "")
if (apiKey.isBlank()) throw LabdbApiError(code, "INVALID_RESPONSE", "register response missing apiKey")
LabdbCredentials.apiKey = apiKey
LabdbCredentials.deviceId = deviceId
LabdbCredentials.lastStatus = status
RegisterResult(deviceId, apiKey, status, message)
}
else -> throw apiError(code, json)
}
}
/** 디바이스 상태 확인. apiKey 무효 시 자격 자동 clear. */
suspend fun checkStatus(): StatusResult = withContext(Dispatchers.IO) {
val key = LabdbCredentials.apiKey
?: return@withContext StatusResult(0, "not_registered", null, null)
val req = Request.Builder()
.url("$BASE/devices/status")
.header("X-API-Key", key)
.get()
.build()
val (code, json) = exec(req)
when (code) {
200 -> {
val status = json.optString("status", "unknown")
LabdbCredentials.lastStatus = status
StatusResult(
httpStatus = 200,
status = status,
deviceName = json.optString("deviceName").ifBlank { null },
sessionCount = if (json.has("sessionCount")) json.optInt("sessionCount") else null,
)
}
401, 404 -> {
LabdbCredentials.clear()
val err = json.optJSONObject("error")
val errCode = err?.optString("code") ?: if (code == 401) "INVALID_API_KEY" else "DEVICE_DELETED"
throw ReregisterRequiredException("$errCode: server requires re-registration")
}
else -> throw apiError(code, json)
}
}
/** /upload/json — measurement.json payload 한 번에 전송. testId+rowIndex 기반 idempotent. */
suspend fun uploadJson(payload: JSONObject): UploadResult = withContext(Dispatchers.IO) {
val key = LabdbCredentials.apiKey
?: throw ReregisterRequiredException("No apiKey — register first")
// PostgreSQL JSONB rejects \u0000 — strip NULL/control chars from payload
sanitizeForJsonb(payload)
val req = Request.Builder()
.url("$BASE/upload/json")
.header("X-API-Key", key)
.post(payload.toString().toRequestBody(JSON))
.build()
val (code, json) = exec(req)
when (code) {
200, 201 -> UploadResult(
ok = json.optBoolean("ok", true),
sessionId = json.optString("sessionId"),
created = json.optBoolean("created", code == 201),
totalRecords = json.optInt("totalRecords"),
inserted = json.optInt("inserted"),
duplicates = json.optInt("duplicates"),
errors = json.optInt("errors"),
)
401 -> { LabdbCredentials.clear(); throw ReregisterRequiredException("INVALID_API_KEY") }
403 -> throw PendingApprovalException()
404 -> { LabdbCredentials.clear(); throw ReregisterRequiredException("DEVICE_DELETED") }
429 -> throw RateLimitedException()
else -> throw apiError(code, json)
}
}
/**
* 세션 삭제 — DELETE /api/v1/sessions/{testId}. Cascade로 모든 records 함께 제거.
* 204(No Content) 또는 200/404(이미 없음) = 성공. 401/403/등은 throw.
*/
suspend fun deleteSession(testId: String): Boolean = withContext(Dispatchers.IO) {
val key = LabdbCredentials.apiKey
?: throw ReregisterRequiredException("No apiKey — register first")
Log.d(TAG, ">> DELETE /sessions/$testId")
val req = Request.Builder()
.url("$BASE/sessions/$testId")
.header("X-API-Key", key)
.delete()
.build()
val (code, json) = exec(req)
when (code) {
200, 204, 404 -> true // 404 = 이미 없음, 멱등 처리
401 -> { LabdbCredentials.clear(); throw ReregisterRequiredException("INVALID_API_KEY") }
403 -> throw PendingApprovalException()
429 -> throw RateLimitedException()
else -> throw apiError(code, json)
}
}
/**
* Recursively strip NULL (0x00) and other C0 control chars from every string value.
* PostgreSQL JSONB rejects escapes with "unsupported Unicode escape sequence"
* (HTTP 500). BLE responses occasionally carry trailing 0x00 padding that survives
* String(US_ASCII) decoding + .trim().
*/
internal fun sanitizeForJsonb(root: JSONObject) {
val keys = root.keys().asSequence().toList()
for (k in keys) {
when (val v = root.opt(k)) {
is String -> root.put(k, stripControl(v))
is JSONObject -> sanitizeForJsonb(v)
is JSONArray -> sanitizeArray(v)
}
}
}
private fun sanitizeArray(arr: JSONArray) {
for (i in 0 until arr.length()) {
when (val v = arr.opt(i)) {
is String -> arr.put(i, stripControl(v))
is JSONObject -> sanitizeForJsonb(v)
is JSONArray -> sanitizeArray(v)
}
}
}
private fun stripControl(s: String): String {
if (s.none { it.code == 0 || it.code == 127 || (it.code in 1..31 && it.code != 9 && it.code != 10 && it.code != 13) }) return s
val sb = StringBuilder(s.length)
for (c in s) {
val code = c.code
if (code == 0 || code == 127 || (code in 1..31 && code != 9 && code != 10 && code != 13)) continue
sb.append(c)
}
return sb.toString()
}
private fun apiError(code: Int, json: JSONObject): LabdbApiError {
val err = json.optJSONObject("error")
val errCode = err?.optString("code") ?: "HTTP_$code"
val msg = err?.optString("message") ?: json.toString().take(200)
return LabdbApiError(code, errCode, msg)
}
private fun exec(req: Request): Pair<Int, JSONObject> {
return try {
client.newCall(req).execute().use { res ->
val raw = res.body?.string().orEmpty()
Log.d(TAG, "<< ${req.method} ${req.url.encodedPath} → HTTP ${res.code} body=${raw.take(500)}")
val obj = if (raw.isBlank()) JSONObject() else runCatching { JSONObject(raw) }.getOrElse { JSONObject() }
res.code to obj
}
} catch (t: IOException) {
Log.w(TAG, "exec failed: ${req.method} ${req.url}", t)
throw LabdbNetworkError(t)
}
}
}
@@ -0,0 +1,46 @@
package com.medithings.vesiscan.services.labdb
import com.medithings.vesiscan.services.KeychainService
/**
* labdb (https://labdb.medithings.net) 자격 증명 저장소.
*
* apiKey/deviceId는 KeychainService(EncryptedSharedPreferences)에 안전 저장.
* 등록은 앱 첫 임상 모드 진입 시 1회만 — 재등록 시 pending device 누적되므로
* 401 INVALID_API_KEY / 404 DEVICE_DELETED 응답을 받았을 때만 clear() 후 재등록.
*/
object LabdbCredentials {
private const val KEY_API_KEY = "labdb.apiKey"
private const val KEY_DEVICE_ID = "labdb.deviceId"
private const val KEY_LAST_STATUS = "labdb.lastStatus"
var apiKey: String?
get() = KeychainService.load(KEY_API_KEY)
set(value) {
if (value == null) KeychainService.delete(KEY_API_KEY)
else KeychainService.save(KEY_API_KEY, value)
}
var deviceId: String?
get() = KeychainService.load(KEY_DEVICE_ID)
set(value) {
if (value == null) KeychainService.delete(KEY_DEVICE_ID)
else KeychainService.save(KEY_DEVICE_ID, value)
}
/** 마지막으로 GET /devices/status 응답에서 받은 상태 ("active"/"pending"/"revoked") */
var lastStatus: String?
get() = KeychainService.load(KEY_LAST_STATUS)
set(value) {
if (value == null) KeychainService.delete(KEY_LAST_STATUS)
else KeychainService.save(KEY_LAST_STATUS, value)
}
val isRegistered: Boolean get() = !apiKey.isNullOrBlank()
fun clear() {
apiKey = null
deviceId = null
lastStatus = null
}
}
@@ -0,0 +1,235 @@
package com.medithings.vesiscan.services.labdb
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* 측정 폴더(measurement.json 포함) → labdb /upload/json 자동 업로드.
*
* 정책:
* - End Session 시 ClinicalSessionStore가 uploadAsync(folder)를 호출
* - 성공: folder/labdb_upload.json (응답 본문)
* - 실패: folder/labdb_upload_error.json (에러 본문) — 폴더는 그대로 유지되어 수동 재시도 가능
*
* dataType = "001" (내부 임상 R&D — 외부 일반 VesiScan "000"과 구분).
* measurement.json의 meta.data_type을 우선 사용, 없으면 DEFAULT_DATA_TYPE fallback.
*
* testId:
* - 폴더명 그대로 사용 (사용자 결정). 단 ^[A-Za-z0-9_-]{1,40}$ 제한 때문에
* 40자 초과 시 앞 31자 + "_" + 8자 SHA-1 prefix로 축약(unique 유지).
*/
object LabdbUploader {
private const val TAG = "LabdbUploader"
private const val DEFAULT_DATA_TYPE = "001"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val isoFmt: SimpleDateFormat
get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US)
/** 비동기 업로드 — fire-and-forget. 결과는 폴더 내 마커 파일로 확인. */
fun uploadAsync(context: Context, folder: File, onResult: ((Boolean, String) -> Unit)? = null) {
scope.launch {
val ok = runCatching { uploadBlocking(folder) }
.onFailure { Log.w(TAG, "upload failed: ${folder.name}", it) }
.getOrElse { e ->
writeError(folder, e)
onResult?.invoke(false, e.message ?: e.javaClass.simpleName)
return@launch
}
onResult?.invoke(true, "uploaded: ${ok.inserted}/${ok.totalRecords} (dup=${ok.duplicates})")
}
}
/** 동기 업로드 — 재시도 버튼/자동 호출 양쪽 사용. */
suspend fun uploadBlocking(folder: File): UploadResult {
// 새 파일명 (measurement_<folderName>.json) 우선, 구 버전 (measurement.json) fallback
val measurementFile = File(folder, "measurement_${folder.name}.json").takeIf { it.exists() }
?: File(folder, "measurement.json").takeIf { it.exists() }
?: folder.listFiles()?.firstOrNull { it.name.startsWith("measurement_") && it.name.endsWith(".json") }
?: throw IllegalStateException("measurement json not found in ${folder.name}")
val measurement = JSONObject(measurementFile.readText())
val payload = buildPayload(folder.name, measurement)
val result = LabdbClient.uploadJson(payload)
writeSuccess(folder, result)
return result
}
fun isUploaded(folder: File): Boolean = File(folder, "labdb_upload.json").exists()
fun lastError(folder: File): String? {
val f = File(folder, "labdb_upload_error.json")
return if (f.exists()) runCatching { JSONObject(f.readText()).optString("message") }.getOrNull() else null
}
/**
* 폴더에 해당하는 labdb 서버 세션 삭제.
* labdb_upload.json에 sessionId가 있으면 그것을 사용, 없으면 folderName → sanitizeTestId 변환.
* 미등록/미업로드 폴더의 testId도 그대로 호출 → labdb가 404로 응답 → 성공 처리.
*/
suspend fun deleteSessionForFolder(folder: File): Boolean {
val uploadMarker = File(folder, "labdb_upload.json")
val testId = if (uploadMarker.exists()) {
runCatching { JSONObject(uploadMarker.readText()).optString("sessionId") }
.getOrNull()?.takeIf { it.isNotBlank() } ?: sanitizeTestId(folder.name)
} else {
sanitizeTestId(folder.name)
}
return if (testId.isNotBlank()) LabdbClient.deleteSession(testId) else true
}
/** measurement.json → labdb payload. 각 cycle = 1 record. */
private fun buildPayload(folderName: String, measurement: JSONObject): JSONObject {
val meta = measurement.optJSONObject("meta") ?: JSONObject()
val cycles = measurement.optJSONArray("cycles") ?: JSONArray()
val testId = sanitizeTestId(folderName)
val dataType = meta.optString("data_type").ifBlank { DEFAULT_DATA_TYPE }
val records = JSONArray()
for (i in 0 until cycles.length()) {
val cycle = cycles.optJSONObject(i) ?: continue
records.put(cycleToRecord(cycle, meta))
}
val params = JSONObject().apply {
// 측정 컨텍스트 — admin 콘솔에서 한눈에 보이도록
put("posture", meta.optString("posture"))
put("step", meta.optString("step"))
put("is_alignment", meta.optBoolean("is_alignment"))
put("examiner", meta.optString("examiner"))
meta.optString("subject_type").takeIf { it.isNotBlank() }?.let { put("subject_type", it) }
meta.optString("subject").takeIf { it.isNotBlank() }?.let { put("subject", it) }
if (meta.has("true_volume_ml")) put("true_volume_ml", meta.optDouble("true_volume_ml"))
if (meta.has("abdomen_thickness_mm")) put("abdomen_thickness_mm", meta.optDouble("abdomen_thickness_mm"))
meta.optString("firmware_version").takeIf { it.isNotBlank() }?.let { put("firmware_version", it) }
meta.optString("hardware_version").takeIf { it.isNotBlank() }?.let { put("hardware_version", it) }
meta.optString("serial_number").takeIf { it.isNotBlank() }?.let { put("serial_number", it) }
put("app_version", meta.optString("app_version_name"))
put("captured_cycles", cycles.length())
}
return JSONObject().apply {
put("testId", testId)
put("dataType", dataType)
// 서버는 sessionName을 export 파일명의 base로 사용 (날짜 포함이면 중복 안 붙임).
// 풀 폴더명을 그대로 보내야 분석 PC 다운로드 파일명과 앱 측 파일명이 일치.
put("sessionName", folderName)
put("memo", meta.optString("notes"))
meta.optLong("ended_at", 0L).takeIf { it > 0 }?.let {
put("savedAt", isoFmt.format(Date(it)))
}
put("params", params)
put("recordCount", records.length())
put("records", records)
}
}
private fun cycleToRecord(cycle: JSONObject, meta: JSONObject): JSONObject {
val rowIndex = cycle.optInt("cycle")
val tsMs = cycle.optLong("timestamp_ms")
val piezo = cycle.optJSONObject("piezo") ?: JSONObject()
val imuArr = cycle.optJSONArray("imu") ?: JSONArray()
// labdb 표준 sensor.imu는 단일 샘플. 우리는 cycle당 다수 샘플(FIFO 순)이라
// 마지막(=가장 최근, piezo 캡처 직전) 샘플을 sensor.imu에 넣고
// 전체를 sensor.imu_samples에 free field로 보존.
val latestImu = if (imuArr.length() > 0) imuArr.optJSONObject(imuArr.length() - 1) else null
val sensor = JSONObject().apply {
latestImu?.let {
put("imu", JSONObject().apply {
put("ax", it.optDouble("ax"))
put("ay", it.optDouble("ay"))
put("az", it.optDouble("az"))
put("gx", it.optDouble("gx"))
put("gy", it.optDouble("gy"))
put("gz", it.optDouble("gz"))
})
}
put("imu_samples", imuArr)
put("imu_sample_count", imuArr.length())
}
val channelsArr = JSONArray()
for (ch in 0..5) {
val data = piezo.optJSONArray("CH$ch") ?: continue
var peak = 0
var peakIdx = 0
for (i in 0 until data.length()) {
val v = data.optInt(i)
if (v > peak) { peak = v; peakIdx = i }
}
channelsArr.put(JSONObject().apply {
put("ch", ch)
put("peak", peak)
put("peakIdx", peakIdx)
put("data", data)
})
}
return JSONObject().apply {
put("rowIndex", rowIndex)
put("datetime", isoFmt.format(Date(tsMs)))
put("commandType", "MTB")
put("sensor", sensor)
put("channels", channelsArr)
// Alignment metadata (alignment session 의 cycle 일 때만 채워짐) — labdb 서버가
// raw.json export 시 함께 내려줘 분석 PC 에서 phase 별 frame 분석 가능.
if (cycle.has("align_phase")) {
put("align_phase", cycle.optString("align_phase"))
put("align_score", cycle.optInt("align_score"))
cycle.optString("align_hint").takeIf { it.isNotBlank() }?.let { put("align_hint", it) }
cycle.optString("align_icon").takeIf { it.isNotBlank() }?.let { put("align_icon", it) }
cycle.optJSONArray("align_detected")?.let { put("align_detected", it) }
cycle.optJSONArray("align_urine_lens")?.let { put("align_urine_lens", it) }
}
}
}
/** ^[A-Za-z0-9_-]{1,40}$ 강제. 40자 초과 시 앞 31자 + "_" + 8자 SHA-1 prefix. */
private fun sanitizeTestId(raw: String): String {
// - 와 _ 이외 특수문자 제거
val cleaned = raw.replace(Regex("[^A-Za-z0-9_-]"), "_")
if (cleaned.length <= 40) return cleaned
val hash = MessageDigest.getInstance("SHA-1").digest(cleaned.toByteArray())
.joinToString("") { "%02x".format(it) }.take(8)
return cleaned.take(31) + "_" + hash
}
private fun writeSuccess(folder: File, r: UploadResult) {
val body = JSONObject().apply {
put("ok", r.ok)
put("sessionId", r.sessionId)
put("created", r.created)
put("totalRecords", r.totalRecords)
put("inserted", r.inserted)
put("duplicates", r.duplicates)
put("errors", r.errors)
put("uploadedAt", isoFmt.format(Date()))
}
File(folder, "labdb_upload.json").writeText(body.toString(2))
// 이전 실패 마커가 있었다면 정리
File(folder, "labdb_upload_error.json").delete()
}
private fun writeError(folder: File, t: Throwable) {
val body = JSONObject().apply {
put("error", t.javaClass.simpleName)
put("message", t.message ?: "")
if (t is LabdbApiError) {
put("httpStatus", t.httpStatus)
put("code", t.code)
}
put("failedAt", isoFmt.format(Date()))
}
File(folder, "labdb_upload_error.json").writeText(body.toString(2))
}
}