diff --git a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt index 3c84798..3257b3a 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt @@ -303,6 +303,19 @@ object HospitalRunStore { return out } + /** + * 저장된 **모든** 환자·날짜 폴더. 없으면 빈 목록. + * + * 업로드 재시도가 이걸 쓴다. 종전에는 "이번 실행의 폴더" 하나만 훑어서, 앱을 다시 + * 켜면 지난 측정이 영원히 고립됐다 — 2026-09-10 병원에서 39건이 그렇게 남았다 + * (망이 막혀 실패 → 앱 재시작 → 올릴 길 없음 → PC 로 꺼내 올려야 했다). + */ + fun allRunDirs(): List = + File(downloads(), ROOT).listFiles() + ?.filter { it.isDirectory } + ?.sortedBy { it.name } + ?: emptyList() + /** 읽기 전용 조회 — [runDir] 과 달리 폴더를 만들지 않는다. */ private fun runDirIfExists(patient: String, day: Date): File? = File(File(downloads(), ROOT), "${dayFmt.format(day)}_${safe(patient)}").takeIf { it.isDirectory } diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt index 3a515c9..e08f0fe 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt @@ -177,6 +177,100 @@ object HospitalLabdbUploader { * 구분할 수 없고, 뒤의 멀쩡한 것까지 막으면 손해가 크다. 실패한 것은 마커가 남아 * 다음 호출에서 다시 시도된다. */ + /** + * 저장된 **전 폴더**의 미업로드 CSV 를 센다. 지난 측정·지난 실행까지 포함한다. + * + * 화면이 "미업로드 N건"을 띄우는 근거. 종전에는 이번 실행의 폴더만 봤다. + */ + fun pendingAll(): List = + com.medithings.vesiscan.services.HospitalRunStore.allRunDirs().flatMap { pending(it) } + + /** + * 저장된 **전 폴더**의 미업로드 CSV 를 전부 올린다. + * + * ## 왜 이 함수가 필요한가 + * 종전의 [uploadPending] 은 넘겨받은 폴더 하나만 훑었고, 화면은 그 폴더를 `remember` + * 로 들고 있었다. 그래서 **앱을 다시 켜면 버튼 자체가 사라졌다** — 2026-09-10 병원에서 + * 망이 막혀 39건이 실패한 뒤, 앱을 재시작하자 올릴 길이 없어져 PC 로 꺼내 올렸다. + * + * 병원 망이 labdb 를 막는 경우(TLS 검사 프록시·DNS 차단)가 실재하므로 "측정은 병원에서, + * 업로드는 나중에"가 정상 경로다. 그 경로가 성립하려면 **지난 것까지 올릴 수 있어야** + * 한다. + */ + suspend fun uploadAllPending(): String { + if (running) return "이미 업로드 중입니다." + val dirs = com.medithings.vesiscan.services.HospitalRunStore.allRunDirs() + if (dirs.isEmpty()) { + lastMessage.value = "올릴 것이 없습니다." + return lastMessage.value!! + } + running = true + try { + val targets = dirs.flatMap { pending(it) } + pendingCount.intValue = targets.size + if (targets.isEmpty()) { + lastMessage.value = "올릴 것이 없습니다." + return lastMessage.value!! + } + var ok = 0 + var fail = 0 + targets.forEachIndexed { i, csv -> + uploadingName.value = "${csv.parentFile?.name}/${csv.name}" + // 매니페스트는 그 CSV 가 속한 폴더에서 찾는다 — 폴더마다 다르다. + val (good, _) = upload(csv, manifestParamsFor(csv)) + if (good) ok++ else fail++ + pendingCount.intValue = (targets.size - (ok + fail)).coerceAtLeast(0) + if (i < targets.lastIndex) delay(GAP_MS) + } + uploadingName.value = null + lastMessage.value = "전체 업로드 — 성공 $ok · 실패 $fail" + return lastMessage.value!! + } finally { + running = false + uploadingName.value = null + } + } + + /** + * 그 CSV 와 같은 폴더의 run 매니페스트에서 파라미터를 찾는다. + * + * [uploadPending] 은 호출부가 매니페스트를 넘겨줬지만, 전 폴더를 도는 [uploadAllPending] + * 은 폴더마다 다르므로 스스로 찾아야 한다. 못 찾으면 null — CSV 헤더만으로도 페이로드는 + * 만들어진다(avg·delay 같은 매니페스트 전용 필드만 빠진다). + */ + private fun manifestParamsFor(csv: File): JSONObject? { + val dir = csv.parentFile ?: return null + val runs = dir.listFiles { f -> + f.isFile && f.name.startsWith("run_") && f.name.endsWith(".json") + } ?: return null + for (f in runs.sortedByDescending { it.name }) { + val o = runCatching { JSONObject(f.readText(Charsets.UTF_8)) }.getOrNull() ?: continue + val combos = o.optJSONArray("combinations") ?: continue + for (i in 0 until combos.length()) { + if (combos.optJSONObject(i)?.optString("file") == csv.name) return o + } + } + // 파일명을 못 맞췄으면 가장 최근 매니페스트라도 쓴다 — 같은 폴더면 조건이 같다. + return runs.maxByOrNull { it.name } + ?.let { runCatching { JSONObject(it.readText(Charsets.UTF_8)) }.getOrNull() } + } + + /** + * 한 CSV 를 **화면 수명과 무관하게** 올린다. + * + * 측정 루프의 `launch` 는 `LaunchedEffect` 스코프라, 조합이 끝나고 화면을 떠나면 + * 전송이 잘린다 — 2026-09-10 실측에서 `The coroutine scope left the composition` 으로 + * 6건이 그렇게 실패했다. 정렬(601)은 같은 이유로 이미 오브젝트 스코프로 옮겼다. + */ + fun uploadAsync(csv: File, manifestParams: JSONObject? = null, onDone: ((String) -> Unit)? = null) { + if (!LabdbCredentials.isRegistered) return + scope.launch { + val (_, m) = upload(csv, manifestParams) + lastMessage.value = "${csv.name} — $m" + onDone?.invoke(m) + } + } + suspend fun uploadPending(runDir: File, manifestParams: JSONObject? = null): String { if (running) return "이미 업로드 중입니다." running = true diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/LabdbClient.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/LabdbClient.kt index 73bd81f..ab48577 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/labdb/LabdbClient.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/LabdbClient.kt @@ -20,7 +20,41 @@ class PendingApprovalException : LabdbException("Device is awaiting administrato 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}") +/** + * 망 문제로 못 보냈다. **원인을 사람 말로 구분한다.** + * + * 종전에는 Java 예외 문구를 그대로 흘렸다(`Unable to resolve host ...`, + * `CertPathValidatorException: Trust anchor ...`). 조작자가 할 일이 원인마다 다른데 그걸 + * 알 수 없었다 — 2026-09-10 병원에서 24건이 실패했을 때 사유가 세 가지였고, 그중 둘은 + * **망을 바꾸면 되는 것**이었다(LTE 에서는 정상 접속된다). + * + * 검증을 느슨하게 하는 선택은 하지 않는다 — 의료기기에서 인증서 검증을 푸는 것은 보안 + * 후퇴다. 대신 **무엇을 해야 하는지** 말해 준다. + */ +class LabdbNetworkError(cause: Throwable) : LabdbException(describe(cause)) { + companion object { + private fun describe(c: Throwable): String { + val m = c.message ?: "" + return when { + // 중간 검사 프록시가 서버 인증서를 바꿔치기한 경우가 대표적이다. + c is javax.net.ssl.SSLHandshakeException || + m.contains("Trust anchor", true) || + m.contains("CertPath", true) -> + "보안 연결이 차단되었습니다 — 이 네트워크가 통신을 검사하고 있을 수 있습니다. " + + "다른 망(LTE·테더링)으로 바꾼 뒤 [미업로드 전부 올리기] 를 눌러 주세요. " + + "데이터는 폰에 그대로 있습니다." + m.contains("Unable to resolve host", true) || + c is java.net.UnknownHostException -> + "서버 주소를 찾을 수 없습니다 — 인터넷이 끊겼거나 이 망이 외부 접속을 " + + "막고 있습니다. 다른 망으로 바꾼 뒤 다시 올려 주세요. 데이터는 폰에 " + + "그대로 있습니다." + c is java.net.SocketTimeoutException || m.contains("timeout", true) -> + "서버 응답이 없습니다 — 잠시 뒤 다시 올려 주세요. 데이터는 폰에 그대로 있습니다." + else -> "Network error: $m" + } + } + } +} 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?) diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt index 66375bb..d5d07a1 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt @@ -130,6 +130,14 @@ fun HospitalModeView(appState: AppState) { // 방금 끝난 실행 폴더 — 업로드 재시도가 이 폴더를 다시 훑는다. var lastRunDir by remember { mutableStateOf(null) } var pendingUploads by remember { mutableIntStateOf(0) } + // 화면에 들어올 때 **전 폴더**를 훑는다. 종전에는 이번 실행의 폴더만 봤고 그 값을 + // `remember` 로 들고 있어서, 앱을 다시 켜면 카드 자체가 사라졌다 — 2026-09-10 병원에서 + // 망이 막혀 39건이 실패한 뒤 올릴 길이 없어진 직접 원인이다. + LaunchedEffect(Unit) { + pendingUploads = withContext(Dispatchers.IO) { + runCatching { HospitalLabdbUploader.pendingAll().size }.getOrDefault(0) + } + } var uploadBusy by remember { mutableStateOf(false) } var uploadMsg by remember { mutableStateOf(null) } @@ -299,12 +307,15 @@ fun HospitalModeView(appState: AppState) { // 조합이 끝나는 즉시 올린다. 한 바퀴(6조합)를 다 돌 때까지 기다리면 // 중간에 앱이 죽거나 자리를 옮겼을 때 그때까지 잰 것이 통째로 남는다. // 실패해도 루프를 세우지 않는다 — 마커가 남아 나중에 버튼으로 올린다. - if (LabdbCredentials.isRegistered) { - launch { - val (_, m) = HospitalLabdbUploader.upload(file) - uploadMsg = "${file.name} — $m" - lastRunDir?.let { d -> pendingUploads = HospitalLabdbUploader.pending(d).size } - } + // **화면 수명과 무관한 스코프에서 올린다.** + // + // 종전에는 `launch { ... }` — 이 LaunchedEffect 의 스코프였다. 조합이 끝나고 + // 화면을 떠나면 전송이 잘렸다: 2026-09-10 병원 실측에서 + // `The coroutine scope left the composition` 으로 6건이 그렇게 실패했다. + // 정렬(601)은 같은 이유로 이미 오브젝트 스코프로 옮겼는데 이쪽만 남아 있었다. + HospitalLabdbUploader.uploadAsync(file) { m -> + uploadMsg = "${file.name} — $m" + pendingUploads = HospitalLabdbUploader.pendingAll().size } results.put(JSONObject().apply { put("freq_mhz", freq.label) @@ -353,7 +364,8 @@ fun HospitalModeView(appState: AppState) { put("anchor_basis", appState.anchorBasis?.wire ?: JSONObject.NULL) put("combinations", results) }, startedAt) - pendingUploads = HospitalLabdbUploader.pending(dir).size + // 전 폴더 기준으로 센다 — 지난 측정이 남아 있으면 그것도 보여야 한다. + pendingUploads = HospitalLabdbUploader.pendingAll().size running = false } } @@ -597,10 +609,13 @@ fun HospitalModeView(appState: AppState) { // 조합이 끝날 때마다 자동으로 올라간다. 인터넷이 끊겨 있었으면 여기 // 남아 있다가 이 버튼으로 한꺼번에 올린다 — 병원 무선망이 불안정한 // 곳이 많아 "나중에 올리기"가 예외가 아니라 정상 경로다. - // isRegistered 로 감싸지 않는다. 미등록일 때도 **몇 건이 못 올라갔는지**는 - // 보여야 한다 — 승인 뒤 [지금 업로드] 로 한꺼번에 보내야 하므로, 그 수를 - // 모르면 무엇을 기다리는지 알 수 없다. - if (lastRunDir != null) { + // **lastRunDir 에 의존하지 않는다.** 그건 이번 실행의 폴더이고 `remember` + // 라서 앱을 다시 켜면 null 이 된다 — 그러면 카드가 사라져 지난 측정을 올릴 + // 길이 없어진다(2026-09-10 병원 39건). + // + // isRegistered 로도 감싸지 않는다. 미등록일 때도 몇 건이 못 올라갔는지는 + // 보여야 한다 — 승인 뒤 한꺼번에 보내야 하므로. + if (pendingUploads > 0 || lastRunDir != null) { Spacer(Modifier.height(12.dp)) Box( Modifier.fillMaxWidth() @@ -612,7 +627,8 @@ fun HospitalModeView(appState: AppState) { ) { Column { Text( - if (pendingUploads > 0) "labdb 미업로드 ${pendingUploads}건" + if (pendingUploads > 0) + "labdb 미업로드 ${pendingUploads}건 (지난 측정 포함)" else "labdb 업로드 완료", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = if (pendingUploads > 0) Color(0xFFE65100) else MlSuccess, @@ -625,11 +641,12 @@ fun HospitalModeView(appState: AppState) { Spacer(Modifier.height(8.dp)) Button( onClick = { - val d = lastRunDir ?: return@Button uploadBusy = true uploadScope.launch { - uploadMsg = HospitalLabdbUploader.uploadPending(d) - pendingUploads = HospitalLabdbUploader.pending(d).size + // 전 폴더를 올린다 — 지난 측정까지. + uploadMsg = HospitalLabdbUploader.uploadAllPending() + pendingUploads = + HospitalLabdbUploader.pendingAll().size uploadBusy = false } }, @@ -640,7 +657,7 @@ fun HospitalModeView(appState: AppState) { shape = RoundedCornerShape(12.dp), ) { Text( - if (uploadBusy) "업로드 중…" else "지금 업로드", + if (uploadBusy) "업로드 중…" else "미업로드 전부 올리기", fontWeight = FontWeight.Bold, ) }