feat(labdb): 정렬(601)도 자동 업로드 — 화면을 떠나는 순간

정렬 업로드만 수동이었다. 그 버튼의 원래 용도가 **"파형이 이상할 때 개발자에게 보내기"**
(메모를 받는 다이얼로그)라, 정상적으로 정렬을 끝내면 601 이 아예 안 올라갔다.

2026-09-09 실기기에서 4위치 80 record 가 폰에만 남아 있었다. 600·001 은 자동인데 601 만
빠져 있어, 같은 세션의 "어디에 붙였나"가 서버에 없는 상태가 된다 — 그 근거가 없으면
600 의 BV 를 위치와 묶을 수 없다.

## 버튼마다 넣지 않고 onDispose 한 곳에서

출구가 넷이다: 뒤로 화살표 · 시스템 뒤로 · [이대로 임상 측정 진행] · 기기 연결 화면으로
이동. 하나만 빠뜨려도 그 경로로 나간 세션은 조용히 사라진다. DisposableEffect 하나로
전부 덮는다.

## 화면 수명과 무관한 스코프가 필요했다

`rememberCoroutineScope` 로 띄우면 화면을 떠나는 순간 취소된다. 그런데 올릴 자연스러운
시점이 **바로 떠나는 순간**이라, 그 스코프로는 영원히 못 올린다.
`HospitalLabdbUploader.uploadAlignAsync` 를 오브젝트 스코프(SupervisorJob + IO)에서 돌린다.
결과는 기존 uploadingName·lastMessage 로 나가므로 다음 화면(병원 모드 카드)이 그대로 읽는다.

## 같은 폴더를 다시 올려도 안전하다

testId 가 `<저장이름>_align` 으로 고정이고 labdb 가 testId+rowIndex 로 멱등이라, 바뀐 것만
반영되고 나머지는 중복으로 센다. 그래도 dirty 깃발을 둔다 — 화면을 오가며 여러 번
들어올 수 있고, 변한 게 없는데 매번 0.3MB 를 보내면 labdb 분당 제한(10건)을 정렬 하나가
먹는다.

## 수동 버튼은 남긴다

용도가 다르다. 이쪽은 **메모를 붙여 지금 보내는** 길이다 — 파형이 이상할 때 개발자가
바로 보고 답해야 하므로 화면을 떠날 때까지 기다릴 수 없다. 주석으로 그 구분을 박았다.

테스트 129개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 18:14:02 +09:00
parent 01c06c7848
commit 8b3b5c0f0a
2 changed files with 61 additions and 0 deletions
@@ -7,6 +7,10 @@ import android.util.Log
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.io.File
@@ -52,6 +56,9 @@ object HospitalLabdbUploader {
@Volatile private var running = false
/** 화면 수명과 무관한 스코프. 정렬 자동 업로드가 화면을 떠난 뒤에도 끝나야 한다. */
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun isUploaded(csv: File): Boolean = markerOk(csv).exists()
fun lastError(csv: File): String? = markerErr(csv).takeIf { it.exists() }
?.let { runCatching { JSONObject(it.readText()).optString("message") }.getOrNull() }
@@ -128,6 +135,28 @@ object HospitalLabdbUploader {
* 증상을 memo 에 적어 다시 보내는 흐름이 실제로 생긴다. labdb 는 같은 testId 면
* 갱신하므로 중복 세션이 쌓이지 않는다.
*/
/**
* 정렬 자동 업로드 — **화면을 벗어나도 끝까지 간다.**
*
* 화면의 `rememberCoroutineScope` 로 띄우면 화면을 떠나는 순간 취소된다. 그런데
* 정렬을 올릴 자연스러운 시점이 바로 **떠나는 순간**(임상 측정으로 넘어가거나 뒤로
* 나갈 때)이라, 그 스코프로는 영원히 못 올린다.
*
* 같은 폴더를 다시 올려도 안전하다. testId 가 `<저장이름>_align` 으로 고정이고
* labdb 가 testId+rowIndex 로 멱등이라, 바뀐 것만 반영되고 나머지는 중복으로 센다.
*
* 결과는 [lastMessage] 와 [uploadingName] 으로 나가므로 다음 화면이 그대로 읽는다.
*/
fun uploadAlignAsync(alignDir: File, memo: String = "") {
if (!LabdbCredentials.isRegistered) return
scope.launch {
uploadingName.value = "정렬 (${alignDir.parentFile?.name ?: alignDir.name})"
val (ok, msg) = uploadAlign(alignDir, memo)
uploadingName.value = null
lastMessage.value = if (ok) "정렬 업로드 — $msg" else "정렬 업로드 실패 — $msg"
}
}
suspend fun uploadAlign(alignDir: File, memo: String = ""): Pair<Boolean, String> {
if (!LabdbCredentials.isRegistered) return false to "labdb 미등록 — 설정에서 등록하세요."
val payload = AlignLabdbPayload.build(alignDir, memo)
@@ -127,6 +127,13 @@ fun AnchorAlignView(appState: AppState) {
var showUpload by remember { mutableStateOf(false) }
/** 위치를 한 번이라도 저장했는가 — 올릴 것이 있는지 판단. */
var hasAlignData by remember { mutableStateOf(false) }
/**
* 마지막 자동 업로드 뒤에 새로 잰 것이 있는가.
*
* 화면을 오가며 여러 번 들어올 수 있어, 변한 게 없는데도 매번 0.3MB 를 보내면
* labdb 분당 제한(10건)을 정렬 하나가 먹는다.
*/
var alignDirty by remember { mutableStateOf(false) }
// ── 방광 용적 ────────────────────────────────────────────────────────────
// 정렬은 지표(nch·ch3·cap_frac)로 위치를 고르는데, 그 지표가 좋다고 BV 가
@@ -166,6 +173,27 @@ fun AnchorAlignView(appState: AppState) {
val done = last?.done == true
// ── 떠날 때 정렬을 올린다 ───────────────────────────────────────────────
// 종전에는 정렬 업로드가 **수동**이었다. 그 버튼의 원래 용도가 "파형이 이상할 때
// 개발자에게 보내기"(메모를 받는 다이얼로그)라, 정상적으로 정렬을 끝내면 601 이
// 아예 안 올라갔다 — 2026-09-09 실기기에서 4위치 80 record 가 폰에만 남아 있었다.
//
// 버튼마다 호출을 넣지 않고 onDispose 한 곳에서 잡는다. 출구가 여럿이다: 뒤로
// 화살표, 시스템 뒤로, [이대로 임상 측정 진행], 기기 연결 화면으로 이동. 하나만
// 빠뜨려도 그 경로로 나간 세션은 조용히 사라진다.
//
// 업로드는 화면 수명과 무관한 스코프에서 돈다([HospitalLabdbUploader.uploadAlignAsync]) —
// 떠나는 순간이 곧 올리는 순간이라, 화면 스코프로는 즉시 취소된다.
DisposableEffect(Unit) {
onDispose {
if (hasAlignData && alignDirty) {
val dir = HospitalRunStore.alignDir(saveName, runStartedAt)
HospitalLabdbUploader.uploadAlignAsync(dir)
alignDirty = false
}
}
}
LaunchedEffect(measuring, forceFinish) {
if (forceFinish) {
last = guide.finish()
@@ -245,6 +273,7 @@ fun AnchorAlignView(appState: AppState) {
// 제일 아까운 것이 이미 받은 데이터라, 이름은 나중에 폴더명으로 맞추면 된다.
val dir = HospitalRunStore.alignDir(saveName, runStartedAt)
hasAlignData = true
alignDirty = true
if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles)) {
error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요."
}
@@ -557,6 +586,9 @@ fun AnchorAlignView(appState: AppState) {
// ── 파형이 이상할 때 ───────────────────────────────────────────
// 판단은 사람이 한다. 이 화면에서 파형을 보는 사람이 유일하게 환자와 프로브를
// 같이 보고 있는 사람이라, 앱이 지표로 거르는 것보다 정확하다.
// 자동 업로드와 별개로 남겨 둔다. 이쪽은 **메모를 붙여 지금 보내는** 길이다 —
// 파형이 이상할 때 개발자가 바로 보고 답해야 하므로, 화면을 떠날 때까지
// 기다릴 수 없다.
if (hasAlignData && LabdbCredentials.isRegistered) {
TextButton(
onClick = { showUpload = true },