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,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 | ||||