Clinical: labdb.medithings.net REST 업로드 통합 (Phase A+B)
End Session 시 measurement.json을 https://labdb.medithings.net/api/v1/upload/json 에 자동 업로드. dataType="000" (VesiScan 6채널). cycle 1개 = record 1개. testId는 폴더명 그대로 사용 (40자 초과 시 prefix+hash로 축약). Files: - LabdbCredentials: apiKey/deviceId를 EncryptedSharedPreferences에 안전 저장 - LabdbClient: register/checkStatus/uploadJson + 5가지 에러 분기 (PendingApproval / Reregister / RateLimit / ApiError / Network) - LabdbUploader: measurement.json → payload 변환 + fire-and-forget 업로드 성공 시 labdb_upload.json, 실패 시 labdb_upload_error.json 마커 작성 ClinicalSessionStore.endMeasurement: cycle>0 + active 상태일 때만 자동 트리거. ClinicalHomeView: labdb 상태 카드(Register/Refresh) + 직전 측정 업로드 상태 표시 + 수동 Upload 재시도 버튼. 오프라인 큐는 아직 없음 (Phase C는 후속). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -139,16 +139,25 @@ object ClinicalSessionStore {
|
||||
File(dir, "meta.json").writeText(meta.toString(2))
|
||||
}
|
||||
|
||||
/** 측정 종료: endedAt → meta.json 갱신 → measurement.json 통합 생성 → currentSession=null */
|
||||
/** 측정 종료: endedAt → meta.json 갱신 → measurement.json 통합 생성 →
|
||||
* labdb 자동 업로드 (apiKey 있고 active 상태일 때만) → currentSession=null */
|
||||
fun endMeasurement(context: Context): File? {
|
||||
val s = currentSession ?: return null
|
||||
s.endedAt = System.currentTimeMillis()
|
||||
writeMeta(context)
|
||||
writeMeasurementJson(context)
|
||||
val capturedCount = cyclesBuffer.size
|
||||
val dir = currentLogDir()
|
||||
lastFinalizedFolder = dir
|
||||
currentSession = null
|
||||
cyclesBuffer.clear()
|
||||
// 자동 업로드 — fire-and-forget. 실패해도 폴더는 그대로, 수동 재시도 가능.
|
||||
if (dir != null && capturedCount > 0) {
|
||||
val creds = com.example.medilightv2android.services.labdb.LabdbCredentials
|
||||
if (creds.isRegistered && creds.lastStatus == "active") {
|
||||
com.example.medilightv2android.services.labdb.LabdbUploader.uploadAsync(context, dir)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.example.medilightv2android.services.labdb
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.example.medilightv2android.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.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 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)
|
||||
}
|
||||
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")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
val obj = if (raw.isBlank()) JSONObject() else runCatching { JSONObject(raw) }.getOrElse { JSONObject() }
|
||||
res.code to obj
|
||||
}
|
||||
} catch (t: IOException) {
|
||||
throw LabdbNetworkError(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.example.medilightv2android.services.labdb
|
||||
|
||||
import com.example.medilightv2android.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,202 @@
|
||||
package com.example.medilightv2android.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 = "000" (VesiScan 6채널). vesiscan_test 프로토콜과 동일.
|
||||
*
|
||||
* testId:
|
||||
* - 폴더명 그대로 사용 (사용자 결정). 단 ^[A-Za-z0-9_-]{1,40}$ 제한 때문에
|
||||
* 40자 초과 시 앞 31자 + "_" + 8자 SHA-1 prefix로 축약(unique 유지).
|
||||
*/
|
||||
object LabdbUploader {
|
||||
private const val TAG = "LabdbUploader"
|
||||
private const val DATA_TYPE = "000"
|
||||
|
||||
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 {
|
||||
val measurementFile = File(folder, "measurement.json")
|
||||
if (!measurementFile.exists()) {
|
||||
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
|
||||
}
|
||||
|
||||
/** 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 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_id").takeIf { it.isNotBlank() }?.let { put("subject_id", 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", DATA_TYPE)
|
||||
put("sessionName", meta.optString("label").ifBlank { 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당 15 샘플이라
|
||||
// 첫 샘플을 sensor.imu에 넣고 전체를 sensor.imu_samples에 free field로 보존.
|
||||
val firstImu = if (imuArr.length() > 0) imuArr.optJSONObject(0) else null
|
||||
val sensor = JSONObject().apply {
|
||||
firstImu?.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)
|
||||
}
|
||||
}
|
||||
|
||||
/** ^[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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user