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))
|
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? {
|
fun endMeasurement(context: Context): File? {
|
||||||
val s = currentSession ?: return null
|
val s = currentSession ?: return null
|
||||||
s.endedAt = System.currentTimeMillis()
|
s.endedAt = System.currentTimeMillis()
|
||||||
writeMeta(context)
|
writeMeta(context)
|
||||||
writeMeasurementJson(context)
|
writeMeasurementJson(context)
|
||||||
|
val capturedCount = cyclesBuffer.size
|
||||||
val dir = currentLogDir()
|
val dir = currentLogDir()
|
||||||
lastFinalizedFolder = dir
|
lastFinalizedFolder = dir
|
||||||
currentSession = null
|
currentSession = null
|
||||||
cyclesBuffer.clear()
|
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
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+186
-12
@@ -26,7 +26,11 @@ import com.example.medilightv2android.AppState
|
|||||||
import com.example.medilightv2android.ble.BleManager
|
import com.example.medilightv2android.ble.BleManager
|
||||||
import com.example.medilightv2android.models.*
|
import com.example.medilightv2android.models.*
|
||||||
import com.example.medilightv2android.services.ClinicalSessionStore
|
import com.example.medilightv2android.services.ClinicalSessionStore
|
||||||
|
import com.example.medilightv2android.services.labdb.LabdbClient
|
||||||
|
import com.example.medilightv2android.services.labdb.LabdbCredentials
|
||||||
|
import com.example.medilightv2android.services.labdb.LabdbUploader
|
||||||
import com.example.medilightv2android.ui.theme.*
|
import com.example.medilightv2android.ui.theme.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clinical R&D 측정 화면 — "한 측정 = 한 라벨 = 한 폴더" 모델.
|
* Clinical R&D 측정 화면 — "한 측정 = 한 라벨 = 한 폴더" 모델.
|
||||||
@@ -65,6 +69,38 @@ fun ClinicalHomeView(appState: AppState) {
|
|||||||
var abdomenText by remember { mutableStateOf(ClinicalSessionStore.lastAbdomenThicknessMm) }
|
var abdomenText by remember { mutableStateOf(ClinicalSessionStore.lastAbdomenThicknessMm) }
|
||||||
|
|
||||||
val lastFolder = ClinicalSessionStore.lastFinalizedFolder
|
val lastFolder = ClinicalSessionStore.lastFinalizedFolder
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// labdb 상태 — 화면 진입 시 + 사용자 액션 후 refresh
|
||||||
|
var labdbApiKey by remember { mutableStateOf(LabdbCredentials.apiKey) }
|
||||||
|
var labdbStatus by remember { mutableStateOf(LabdbCredentials.lastStatus ?: "unknown") }
|
||||||
|
var labdbBusy by remember { mutableStateOf(false) }
|
||||||
|
var labdbMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
// 마지막 측정 업로드 상태 — Last saved 카드용
|
||||||
|
var lastUploaded by remember(lastFolder) {
|
||||||
|
mutableStateOf(lastFolder?.let { LabdbUploader.isUploaded(it) } ?: false)
|
||||||
|
}
|
||||||
|
var lastUploadError by remember(lastFolder) {
|
||||||
|
mutableStateOf(lastFolder?.let { LabdbUploader.lastError(it) })
|
||||||
|
}
|
||||||
|
var uploadBusy by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// 진입 시 status 자동 확인 (apiKey 있을 때만)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (LabdbCredentials.isRegistered) {
|
||||||
|
labdbBusy = true
|
||||||
|
runCatching { LabdbClient.checkStatus() }
|
||||||
|
.onSuccess {
|
||||||
|
labdbStatus = it.status
|
||||||
|
labdbApiKey = LabdbCredentials.apiKey
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
labdbMessage = it.message
|
||||||
|
labdbApiKey = LabdbCredentials.apiKey
|
||||||
|
}
|
||||||
|
labdbBusy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -126,23 +162,161 @@ fun ClinicalHomeView(appState: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 직전 측정 결과 표시
|
// labdb 서버 상태/등록 카드
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
val labdbBg = when {
|
||||||
|
!LabdbCredentials.isRegistered -> Color(0xFF9E9E9E).copy(alpha = 0.15f)
|
||||||
|
labdbStatus == "active" -> Color(0xFF2196F3).copy(alpha = 0.12f)
|
||||||
|
labdbStatus == "pending" -> Color(0xFFFF9800).copy(alpha = 0.15f)
|
||||||
|
else -> Color(0xFFE53935).copy(alpha = 0.12f)
|
||||||
|
}
|
||||||
|
Card(shape = RoundedCornerShape(10.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = labdbBg)) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp).fillMaxWidth()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("labdb.medithings.net", fontSize = 11.sp, color = MlSecondaryText)
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
!LabdbCredentials.isRegistered -> "Not registered"
|
||||||
|
labdbBusy -> "Checking…"
|
||||||
|
else -> "Status: $labdbStatus"
|
||||||
|
},
|
||||||
|
fontSize = 14.sp, fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
LabdbCredentials.deviceId?.let {
|
||||||
|
Text("deviceId: $it", fontSize = 10.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!LabdbCredentials.isRegistered) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (labdbBusy) return@Button
|
||||||
|
labdbBusy = true
|
||||||
|
labdbMessage = null
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
LabdbClient.register(
|
||||||
|
context,
|
||||||
|
appVersion = try {
|
||||||
|
context.packageManager
|
||||||
|
.getPackageInfo(context.packageName, 0)
|
||||||
|
.versionName ?: "unknown"
|
||||||
|
} catch (_: Exception) { "unknown" }
|
||||||
|
)
|
||||||
|
}.onSuccess {
|
||||||
|
labdbApiKey = it.apiKey
|
||||||
|
labdbStatus = it.status
|
||||||
|
labdbMessage = it.message
|
||||||
|
Toast.makeText(context,
|
||||||
|
"Registered. Awaiting approval.",
|
||||||
|
Toast.LENGTH_LONG).show()
|
||||||
|
}.onFailure {
|
||||||
|
labdbMessage = it.message
|
||||||
|
}
|
||||||
|
labdbBusy = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !labdbBusy,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
|
||||||
|
) { Text("Register", color = Color.White, fontSize = 12.sp) }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
if (labdbBusy) return@OutlinedButton
|
||||||
|
labdbBusy = true
|
||||||
|
labdbMessage = null
|
||||||
|
scope.launch {
|
||||||
|
runCatching { LabdbClient.checkStatus() }
|
||||||
|
.onSuccess {
|
||||||
|
labdbStatus = it.status
|
||||||
|
labdbApiKey = LabdbCredentials.apiKey
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
labdbMessage = it.message
|
||||||
|
labdbApiKey = LabdbCredentials.apiKey
|
||||||
|
}
|
||||||
|
labdbBusy = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !labdbBusy
|
||||||
|
) { Text("Refresh", fontSize = 12.sp) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labdbMessage?.let {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(it, fontSize = 11.sp, color = Color(0xFFE53935))
|
||||||
|
}
|
||||||
|
if (LabdbCredentials.isRegistered && labdbStatus == "pending") {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text("admin@medithings.net 승인 후 자동 업로드됩니다.",
|
||||||
|
fontSize = 11.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 직전 측정 결과 + 업로드 상태/재시도
|
||||||
if (lastFolder != null) {
|
if (lastFolder != null) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
val savedBg = when {
|
||||||
|
lastUploaded -> Color(0xFF4CAF50).copy(alpha = 0.10f)
|
||||||
|
lastUploadError != null -> Color(0xFFE53935).copy(alpha = 0.10f)
|
||||||
|
else -> Color(0xFF4CAF50).copy(alpha = 0.06f)
|
||||||
|
}
|
||||||
Card(
|
Card(
|
||||||
shape = RoundedCornerShape(10.dp),
|
shape = RoundedCornerShape(10.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF4CAF50).copy(alpha = 0.08f))
|
colors = CardDefaults.cardColors(containerColor = savedBg)
|
||||||
) {
|
) {
|
||||||
Row(
|
Column(modifier = Modifier.padding(12.dp).fillMaxWidth()) {
|
||||||
modifier = Modifier.padding(12.dp).fillMaxWidth(),
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
verticalAlignment = Alignment.CenterVertically
|
Icon(Icons.Default.CheckCircle, null,
|
||||||
) {
|
tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp))
|
||||||
Icon(Icons.Default.CheckCircle, null,
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp))
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Text("Last saved", fontSize = 11.sp, color = MlSecondaryText)
|
||||||
Column {
|
Text(lastFolder.name, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||||
Text("Last saved", fontSize = 11.sp, color = MlSecondaryText)
|
Text(
|
||||||
Text(lastFolder.name, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
when {
|
||||||
|
lastUploaded -> "✓ labdb 업로드 완료"
|
||||||
|
lastUploadError != null -> "✗ 업로드 실패: $lastUploadError"
|
||||||
|
uploadBusy -> "업로드 중…"
|
||||||
|
else -> "로컬 저장만 (서버 미업로드)"
|
||||||
|
},
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = when {
|
||||||
|
lastUploaded -> Color(0xFF4CAF50)
|
||||||
|
lastUploadError != null -> Color(0xFFE53935)
|
||||||
|
else -> MlSecondaryText
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!lastUploaded && LabdbCredentials.isRegistered) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (uploadBusy) return@Button
|
||||||
|
uploadBusy = true
|
||||||
|
scope.launch {
|
||||||
|
runCatching { LabdbUploader.uploadBlocking(lastFolder) }
|
||||||
|
.onSuccess {
|
||||||
|
lastUploaded = true
|
||||||
|
lastUploadError = null
|
||||||
|
Toast.makeText(context,
|
||||||
|
"Uploaded: ${it.inserted}/${it.totalRecords}",
|
||||||
|
Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
lastUploadError = it.message ?: it.javaClass.simpleName
|
||||||
|
// status도 갱신 (401/403/404 케이스 반영)
|
||||||
|
labdbApiKey = LabdbCredentials.apiKey
|
||||||
|
labdbStatus = LabdbCredentials.lastStatus ?: "unknown"
|
||||||
|
}
|
||||||
|
uploadBusy = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !uploadBusy,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
|
||||||
|
) { Text("Upload", color = Color.White, fontSize = 12.sp) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user