feat(labdb): demo-final 도 미업로드 세션 자동 재시도 + endMeasurement 자동 업로드

demo-final 에서도 clinical mode 테스트 중인데 매 세션마다 Upload 버튼을
눌러야 반영되는 문제. tab-navigation / cloud-mvp 브랜치의 자동 재시도
로직을 demo-final 구조에 맞춰 축소 이식.

원인:
- demo-final ClinicalSessionStore.endMeasurement 자체에 자동 업로드
  로직이 아예 없었음 (주석: "labdb 업로드는 자동 X — Upload 버튼으로
  수동 트리거").
- recentFinalizedFolders / capturedCount 히스토리 구조도 없어 단순
  cherry-pick 은 불가.

수정:
- endMeasurement: isRegistered (apiKey 존재) 면 uploadAsync 자동 호출.
- LabdbAutoRetry object 신설 (services/labdb/LabdbAutoRetry.kt):
  demo-final 은 lastFinalizedFolder 단일 값만 있으므로
  listOfNotNull(lastFinalizedFolder) 로 축소. tab-nav/cloud-mvp 는
  recentFinalizedFolders 리스트 사용 (그쪽 브랜치 코드는 그대로).
- ClinicalHomeView LaunchedEffect(Unit) 에서 자동 트리거 + 상단 배너 UI:
  * 진행 중: "자동 업로드 진행 중 · 남은 세션 N · <name>" (파랑, spinner)
  * 완료: "N uploaded, M failed" (초록/주황) + 확인 버튼

효과:
- 세션 종료 후 뒤로가기 → 자동 업로드 시도 → 실패해도 Clinical Home
  재진입 시 재시도. 사용자 개입 0.
- 여러 세션 밀린 상황도 사용자가 다음 세션 진입할 때마다 이전 세션이
  하나씩 재시도됨 (demo-final 구조 특성).
- 기존 폴더 구조 / measurement.json 포맷 100% 유지.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 14:09:50 +09:00
parent 990793170e
commit db6e7e1a5c
3 changed files with 168 additions and 2 deletions
@@ -197,8 +197,9 @@ object ClinicalSessionStore {
metaJsonFile(dir).writeText(meta.toString(2)) metaJsonFile(dir).writeText(meta.toString(2))
} }
/** 측정 종료: endedAt → meta.json 갱신 → measurement.json 통합 생성 → currentSession=null. /** 측정 종료: endedAt → meta.json 갱신 → measurement.json 통합 생성 →
* labdb 업로드는 자동 X — Clinical Home 의 'Last saved' 카드의 Upload 버튼으로 수동 트리거. */ * labdb 자동 업로드 (apiKey 있을 때만, fire-and-forget) → currentSession=null.
* 실패 폴더는 Clinical Home 재진입 시 LabdbAutoRetry 가 자동 재시도. */
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()
@@ -208,6 +209,14 @@ object ClinicalSessionStore {
lastFinalizedFolder = dir lastFinalizedFolder = dir
currentSession = null currentSession = null
cyclesBuffer.clear() cyclesBuffer.clear()
// 2026-07-09: 자동 업로드 추가. apiKey 만 있으면 시도 (status 는 서버 응답으로 판단).
// 실패해도 폴더는 그대로 유지 → 다음 ClinicalHome 진입 시 LabdbAutoRetry 재시도.
if (dir != null) {
val creds = com.medithings.vesiscan.services.labdb.LabdbCredentials
if (creds.isRegistered) {
com.medithings.vesiscan.services.labdb.LabdbUploader.uploadAsync(context, dir)
}
}
return dir return dir
} }
@@ -0,0 +1,96 @@
package com.medithings.vesiscan.services.labdb
import android.content.Context
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.File
/**
* labdb 미업로드 세션 자동 재시도.
*
* ClinicalHomeView 진입 시 retryPending(context) 를 호출하면:
* - ClinicalSessionStore.lastFinalizedFolder 를 스캔
* - labdb_upload.json 마커가 없는 폴더만 대상
* - LabdbUploader.uploadBlocking 을 순차 실행
* - 진행 상황을 pendingCount / uploadingName / lastResultMessage state 로 노출
* - UI (ClinicalHomeView) 가 관찰해 배너 표시
*
* demo-final 은 recentFinalizedFolders 히스토리 구조가 없어 lastFinalizedFolder
* 하나만 대상. 사용자가 세션 완료 → Clinical Home 재진입 → 자동 재업로드 → 다음
* 세션 진행하며 자연스럽게 순차 처리 가능.
*
* 조건:
* - LabdbCredentials.isRegistered = true (apiKey 존재)
* - 폴더에 measurement_<name>.json 또는 measurement.json 존재
*
* 실패한 폴더는 labdb_upload_error.json 만 남기고 계속 진행. 다음 호출 시 재시도.
*/
object LabdbAutoRetry {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** 재시도 대기 중인 폴더 수 (0 = 없음). UI 배너 표시용. */
val pendingCount = mutableIntStateOf(0)
/** 현재 업로드 중인 세션 이름 (null = idle). */
val uploadingName = mutableStateOf<String?>(null)
/** 마지막 배치 종료 후 요약 메시지. null = 표시 안 함. */
val lastResultMessage = mutableStateOf<String?>(null)
/** 동시 실행 방지. */
@Volatile private var running = false
/**
* 최근 finalize 된 세션 중 미업로드 폴더를 자동 재시도.
* 이미 실행 중이면 no-op. 완료 후 lastResultMessage 세팅.
*/
fun retryPending(context: Context) {
if (running) return
if (!LabdbCredentials.isRegistered) return
val last = com.medithings.vesiscan.services.ClinicalSessionStore.lastFinalizedFolder
val recent = listOfNotNull(last)
val pending = recent.filter { needsUpload(it) }
if (pending.isEmpty()) return
running = true
pendingCount.intValue = pending.size
lastResultMessage.value = null
scope.launch {
var uploaded = 0
var failed = 0
for (folder in pending) {
uploadingName.value = folder.name
val ok = runCatching { LabdbUploader.uploadBlocking(folder) }.isSuccess
if (ok) uploaded++ else failed++
pendingCount.intValue = (pending.size - (uploaded + failed)).coerceAtLeast(0)
}
uploadingName.value = null
lastResultMessage.value = buildString {
append("Auto upload: ")
append("$uploaded uploaded")
if (failed > 0) append(", $failed failed")
}
running = false
}
}
/** 폴더가 measurement.json 을 가지고 있으면서 labdb_upload.json 마커가 없는지. */
private fun needsUpload(folder: File): Boolean {
if (!folder.exists() || !folder.isDirectory) return false
if (File(folder, "labdb_upload.json").exists()) return false
val hasNamed = File(folder, "measurement_${folder.name}.json").exists()
val hasLegacy = File(folder, "measurement.json").exists()
val hasAny = folder.listFiles()?.any {
it.name.startsWith("measurement_") && it.name.endsWith(".json")
} == true
return hasNamed || hasLegacy || hasAny
}
/** 배너 dismiss — 사용자 확인 후 lastResultMessage 지움. */
fun dismissResult() {
lastResultMessage.value = null
}
}
@@ -12,6 +12,7 @@ import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -110,6 +111,16 @@ fun ClinicalHomeView(appState: AppState) {
} }
} }
// 2026-07-09: ClinicalHome 진입 시 미업로드 세션 자동 재시도.
// endMeasurement 자동 업로드가 네트워크 순간 이슈 등으로 실패한 폴더를 다음 진입
// 시점에 자동으로 다시 올림 — 사용자가 업로드 버튼 누르는 걸 잊어도 결국 반영됨.
LaunchedEffect(Unit) {
com.medithings.vesiscan.services.labdb.LabdbAutoRetry.retryPending(context)
}
val autoUploadPending by com.medithings.vesiscan.services.labdb.LabdbAutoRetry.pendingCount
val autoUploadingName by com.medithings.vesiscan.services.labdb.LabdbAutoRetry.uploadingName
val autoUploadResult by com.medithings.vesiscan.services.labdb.LabdbAutoRetry.lastResultMessage
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -131,6 +142,56 @@ fun ClinicalHomeView(appState: AppState) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// 2026-07-09: 자동 업로드 진행/결과 배너.
if (autoUploadPending > 0 || autoUploadingName != null) {
Row(
modifier = Modifier.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color(0xFF1976D2).copy(alpha = 0.12f))
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = Color(0xFF1976D2)
)
Spacer(Modifier.width(10.dp))
Column {
Text("자동 업로드 진행 중 · 남은 세션 $autoUploadPending",
fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = Color(0xFF1976D2))
autoUploadingName?.let {
Text(it, fontSize = 11.sp, color = Color(0xFF1976D2).copy(alpha = 0.8f),
maxLines = 1)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
} else if (autoUploadResult != null) {
val msg = autoUploadResult ?: ""
val hasFail = msg.contains("failed")
val bg = if (hasFail) Color(0xFFFF9800) else Color(0xFF4CAF50)
Row(
modifier = Modifier.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(bg.copy(alpha = 0.15f))
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
if (hasFail) Icons.Filled.Warning else Icons.Filled.CheckCircle,
contentDescription = null, tint = bg, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(10.dp))
Text(msg, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = bg,
modifier = Modifier.weight(1f))
TextButton(onClick = {
com.medithings.vesiscan.services.labdb.LabdbAutoRetry.dismissResult()
}) { Text("확인", color = bg, fontSize = 12.sp) }
}
Spacer(modifier = Modifier.height(8.dp))
}
// BLE 연결 상태 // BLE 연결 상태
Card( Card(
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),