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:
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user