From 3a126bdf832ab59780286cf212274ae44d79b7f2 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 04:19:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EB=A9=94=EC=9D=BC=20=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=ED=99=94=EB=A9=B4=202=EC=B0=A8=20=E2=80=94=20?= =?UTF-8?q?=EC=B2=A8=EB=B6=80=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=98=AC=EB=A6=AC=EA=B8=B0=EB=A5=BC=20=EB=8D=94=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [파일 첨부]가 안드로이드 고르는 창(권한 없음)을 연다. 고른 파일은 앱 임시 폴더에 복사해 두고 그 복사본을 multipart 로 둘씩 올린다 — 올리기에 실패하거나 서버가 첨부를 찾지 못해도(MAIL_004) 다시 고르지 않고 다시 올리려고. - 사전 검사 · 보내기 막기는 웹과 같은 차례, 한도는 GET /api/mail-quota - 기기가 같은 차례로 미리 걸러 넣을 파일만 복사한다(content:// 만, 판정은 Dart 가 다시) - 다 올리면 서버가 정리한 이름 · 크기로 싣는다(이름 255자 — 웹과 같다) - MAIL_004 뒤 다시 올릴 수 없는 파일은 빼기 전까지 저장 · 보내기를 막는다 - 가져오는 · 올리는 동안 [보내기]를 막고(가져오기는 10분 시한), 올리는 중 나가기는 따로 묻는다 - 화면이 끝나거나 직원이 바뀌거나 앱을 띄울 때 복사본을 지운다 리뷰(M1~M4 · L 다섯 · 다시 본 리뷰의 L 하나)를 고쳤다. 시험 690 → 750. Co-Authored-By: Claude Opus 5.5 --- .../kr/it/arasystem/app/MainActivity.kt | 428 ++++++++- flutter/lib/core/files/file_names.dart | 23 +- flutter/lib/core/files/picked_files.dart | 330 +++++++ flutter/lib/core/utils/file_size.dart | 13 +- flutter/lib/core/utils/korean_text.dart | 11 +- .../mail/data/models/compose_models.dart | 41 +- .../data/repositories/compose_repository.dart | 70 +- .../mail/domain/compose_attachments.dart | 136 +++ .../features/mail/domain/compose_send.dart | 43 +- .../mail/presentation/mail_texts.dart | 60 +- .../pages/message_compose_page.dart | 86 +- .../providers/attachment_uploads.dart | 214 +++++ .../providers/compose_controller.dart | 415 +++++++- .../presentation/providers/compose_state.dart | 132 ++- .../presentation/widgets/compose_bars.dart | 2 +- .../widgets/compose_sections.dart | 295 ++++-- flutter/lib/main.dart | 7 +- .../test/core/files/picked_files_test.dart | 338 +++++++ .../mail/attachment_uploads_test.dart | 203 ++++ .../mail/compose_attachments_test.dart | 361 +++++++ .../mail/compose_repository_test.dart | 201 ++++ .../features/mail/compose_rules_test.dart | 1 + .../mail/message_compose_page_test.dart | 883 +++++++++++++++++- flutter/test/support/compose_fakes.dart | 81 +- flutter/test/support/fakes.dart | 13 + flutter/test/support/file_fakes.dart | 101 ++ flutter/test/support/mail_harness.dart | 8 +- 27 files changed, 4360 insertions(+), 136 deletions(-) create mode 100644 flutter/lib/core/files/picked_files.dart create mode 100644 flutter/lib/features/mail/domain/compose_attachments.dart create mode 100644 flutter/lib/features/mail/presentation/providers/attachment_uploads.dart create mode 100644 flutter/test/core/files/picked_files_test.dart create mode 100644 flutter/test/features/mail/attachment_uploads_test.dart create mode 100644 flutter/test/features/mail/compose_attachments_test.dart diff --git a/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/MainActivity.kt b/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/MainActivity.kt index a550c2c..7464f20 100644 --- a/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/MainActivity.kt +++ b/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/MainActivity.kt @@ -1,22 +1,52 @@ package kr.it.arasystem.app +import android.app.Activity import android.content.ActivityNotFoundException +import android.content.ContentResolver import android.content.Intent +import android.net.Uri +import android.provider.OpenableColumns import androidx.core.content.FileProvider import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import java.io.File +import java.io.FileOutputStream import java.io.IOException +import java.security.SecureRandom +import java.util.Locale /** * 앱 화면 하나 — Flutter 가 그린다. * - * 파일 채널(`kr.it.arasystem.app/files`): 앱이 받은 파일(메일 첨부)을 다른 앱으로 연다 — Dart `core/files/downloaded_files.dart` 의 - * `PlatformFileOpener` 가 부른다. 앱 임시 폴더의 [DOWNLOADS_DIR] 안 파일만, FileProvider 주소와 읽기 권한 1회로 `ACTION_VIEW` 한다. - * 결과는 글 하나 — opened · noApp · notFound · failed. 경로 · 주소는 로그에 남기지 않는다. + * 파일 채널(`kr.it.arasystem.app/files`) — 권한 · 새 플러그인 없이 앱 임시 폴더 두 곳만 쓴다: + * - `open`: 앱이 받은 파일(메일 첨부)을 다른 앱으로 연다 — Dart `core/files/downloaded_files.dart` 의 `PlatformFileOpener` 가 부른다. + * 앱 임시 폴더의 [DOWNLOADS_DIR] 안 파일만, FileProvider 주소와 읽기 권한 1회로 `ACTION_VIEW` 한다. + * 결과는 글 하나 — opened · noApp · notFound · failed. + * - `pick`: 고르는 창(`ACTION_GET_CONTENT` — 모든 형식 · 여러 개)으로 고른 파일 가운데 넣을 파일만 앱 임시 폴더의 [UPLOADS_DIR] 에 복사한다 — + * Dart `core/files/picked_files.dart` 의 `PlatformPickedFileStore` 가 부른다. 고르는 창에서는 형식으로 거르지 않는다(hwp 가 숨는다). + * 고른 뒤 Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계)로 미리 걸러 넣지 않을 파일은 읽지 않는다 — 판정은 Dart 가 다시 한다. + * 결과는 `{outcome, files: [{name, size, status, path}]}` — 짝: Dart `PickResult.parse` · `PickedFile.fromChannel`. + * 경로 · 주소 · 파일 이름은 로그에 남기지 않는다. */ class MainActivity : FlutterActivity() { + /** 고르는 창이 열려 있는 동안 기다리는 답 — 한 번에 하나 */ + private var pendingPick: PendingPick? = null + + /** 고르기 한 번의 한도 — Dart 가 넘긴다(짝: `PlatformPickedFileStore.pick` 의 인자) */ + private class PickLimits( + /** 파일 하나의 한도 — 그 + 1바이트를 넘으면 복사를 멈춘다 */ + val maxBytes: Long, + /** 넣을 수 있는 남은 개수 */ + val maxCount: Int, + /** 넣을 수 있는 남은 합계 바이트 */ + val maxTotalBytes: Long, + /** 허용 확장자(소문자, 점 없이) — Dart 의 형식 표 */ + val extensions: Set, + ) + + private class PendingPick(val result: MethodChannel.Result, val limits: PickLimits) + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FILES_CHANNEL) @@ -25,6 +55,15 @@ class MainActivity : FlutterActivity() { OPEN_METHOD -> result.success( openFile(call.argument("path"), call.argument("type")), ) + PICK_METHOD -> pickFiles( + limitsOf( + call.argument(ARG_MAX_BYTES), + call.argument(ARG_MAX_COUNT), + call.argument(ARG_MAX_TOTAL_BYTES), + call.argument>(ARG_EXTENSIONS), + ), + result, + ) else -> result.notImplemented() } } @@ -71,14 +110,354 @@ class MainActivity : FlutterActivity() { return if (given != null && MIME_PATTERN.matches(given)) given else GENERIC_TYPE } + // ── 고르기 ───────────────────────────────────────── + + /** 넘겨받은 한도 — 모양이 틀리면 null */ + private fun limitsOf(maxBytes: Number?, maxCount: Number?, maxTotalBytes: Number?, extensions: List<*>?): PickLimits? { + if (maxBytes == null || maxCount == null || maxTotalBytes == null || extensions == null) return null + if (maxBytes.toLong() < 0 || maxCount.toInt() < 1 || maxTotalBytes.toLong() < 0) return null + return PickLimits( + maxBytes = maxBytes.toLong(), + maxCount = maxCount.toInt(), + maxTotalBytes = maxTotalBytes.toLong(), + extensions = extensions.filterIsInstance().map { it.lowercase(Locale.ROOT) }.toSet(), + ) + } + + /** + * 고르는 창을 연다 — 권한이 필요 없다(고르는 창이 고른 파일의 읽기 권한만 준다). 답은 고른 뒤 [onActivityResult] 에서 한다 + */ + private fun pickFiles(limits: PickLimits?, result: MethodChannel.Result) { + if (limits == null) { + result.success(outcomeOf(OUTCOME_FAILED)) + return + } + if (pendingPick != null) { + result.success(outcomeOf(OUTCOME_BUSY)) + return + } + // 형식으로 거르지 않는다 — hwp 는 기기 · 앱마다 형식이 달라 창에서 거르면 숨는다(고른 뒤 확장자로 거른다) + val intent = Intent(Intent.ACTION_GET_CONTENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(ANY_TYPE) + .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + try { + startActivityForResult(intent, PICK_REQUEST_CODE) + } catch (error: ActivityNotFoundException) { + result.success(outcomeOf(OUTCOME_NO_APP)) + return + } + pendingPick = PendingPick(result, limits) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode != PICK_REQUEST_CODE) { + super.onActivityResult(requestCode, resultCode, data) + return + } + // 고르는 동안 앱이 정리됐다가 다시 떴으면 기다리는 답이 없다 — 복사하지 않는다(Dart 의 쓰기 화면도 없다) + val pending = pendingPick ?: return + pendingPick = null + val uris = if (resultCode == Activity.RESULT_OK && data != null) pickedUris(data) else emptyList() + if (uris.isEmpty()) { + pending.result.success(outcomeOf(OUTCOME_CANCELED)) + return + } + // 복사는 화면 스레드 밖에서 — 클라우드 파일은 읽는 동안 받는다. 답은 화면 스레드에서 한 번만 + Thread { + val files = ArrayList>() + val reply: Map = try { + copyPicked(uris, pending.limits, files) + mapOf(KEY_OUTCOME to OUTCOME_PICKED, KEY_FILES to files) + } catch (error: Throwable) { + // 뜻밖의 실패(권한 · 메모리 등) — 만든 복사본을 지우고 실패로 답한다(Dart 가 기다리며 멈춰 있지 않게) + for (file in files) { + (file[KEY_PATH] as? String)?.let { deleteCopy(it) } + } + outcomeOf(OUTCOME_FAILED) + } + runOnUiThread { pending.result.success(reply) } + }.start() + } + + /** 고른 주소 — 여러 개면 clipData, 하나면 data */ + private fun pickedUris(data: Intent): List { + val clip = data.clipData + if (clip != null && clip.itemCount > 0) { + return (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri } + } + return listOfNotNull(data.data) + } + + /** + * 고른 차례대로 넣을 파일만 복사한다 — **차례는 Dart 사전 검사(`compose_attachments.dart` 의 `precheckPickedFile`)와 같다**: + * 개수(넣은 수가 [PickLimits.maxCount]) → 형식(확장자 — 짝: Dart `displayFileName` · `extensionOf`) → 파일당(알려 준 크기) → + * 합계(알려 준 크기로 먼저, 복사한 실제 크기로 다시 — 넘으면 그 복사본을 지운다). 넣지 않을 파일은 읽지 않는다. + * 센 것은 넣을 파일뿐이다 — 형식 · 크기 · 합계 · 읽지 못함으로 빠진 파일은 개수 · 합계에 들지 않는다(웹과 같다) + */ + private fun copyPicked(uris: List, limits: PickLimits, files: MutableList>) { + val root = File(cacheDir, UPLOADS_DIR) + var count = 0 + var bytes = 0L + for (uri in uris) { + // content:// 만 읽는다 — 고른 앱이 file:// 로 우리 앱 안 파일을 건네는 길을 닫는다(묻지도 읽지도 않는다). + // 이름은 주소의 마지막 칸으로만 — Dart 가 "가져오지 못함"(형식이 틀리면 형식)으로 알린다 + if (uri.scheme != ContentResolver.SCHEME_CONTENT) { + files.add(fileOf(uri.lastPathSegment, null, STATUS_FAILED, null)) + continue + } + val info = queryInfo(uri) + val name = info.first + val reported = info.second + val file = when { + count >= limits.maxCount -> fileOf(name, reported, STATUS_SKIPPED, null) + extensionOf(displayFileName(name)) !in limits.extensions -> fileOf(name, reported, STATUS_NOT_ALLOWED, null) + reported != null && reported > limits.maxBytes -> fileOf(name, reported, STATUS_TOO_LARGE, null) + reported != null && bytes + reported > limits.maxTotalBytes -> fileOf(name, reported, STATUS_OVER_TOTAL, null) + else -> withinTotal(copyOne(uri, name, reported, limits.maxBytes, root), name, bytes, limits) + } + if (file[KEY_STATUS] == STATUS_OK) { + count++ + bytes += file[KEY_SIZE] as Long + } + files.add(file) + } + } + + /** 복사한 실제 크기로 합계를 다시 본다 — 고른 앱이 크기를 알려 주지 않았거나 틀렸을 수 있다. 넘으면 복사본을 지운다 */ + private fun withinTotal(copied: Map, name: String?, bytes: Long, limits: PickLimits): Map { + if (copied[KEY_STATUS] != STATUS_OK) return copied + val size = copied[KEY_SIZE] as Long + if (bytes + size <= limits.maxTotalBytes) return copied + (copied[KEY_PATH] as? String)?.let { deleteCopy(it) } + return fileOf(name, size, STATUS_OVER_TOTAL, null) + } + + /** 보이는 이름 · 크기 — 고른 앱이 알려 주지 않으면 null */ + private fun queryInfo(uri: Uri): Pair { + var name: String? = null + var size: Long? = null + try { + contentResolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0 && !cursor.isNull(nameIndex)) name = cursor.getString(nameIndex) + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) size = cursor.getLong(sizeIndex) + } + } + } catch (error: Exception) { + // 고른 앱마다 던지는 것이 다르다(권한 · 주소 모양) — 이름 · 크기 없이 본다(이름이 없으면 형식으로 빠진다) + } + return Pair(name, size?.takeIf { it >= 0 }) + } + + /** + * 하나를 `cacheDir/uploads/<무작위 16자>/<정리한 이름>` 에 복사한다 — [maxBytes] + 1바이트를 넘으면 멈추고 쓰다 만 복사본을 지운다. + * 자리는 열기 채널처럼 canonical 경로로 그 폴더 안인지 다시 본다 + */ + private fun copyOne(uri: Uri, name: String?, reported: Long?, maxBytes: Long, root: File): Map { + val folder = File(root, randomFolderName()) + val target = File(folder, safeFileName(name)) + try { + if (!target.canonicalPath.startsWith(root.canonicalPath + File.separator)) { + return fileOf(name, reported, STATUS_FAILED, null) + } + } catch (error: IOException) { + return fileOf(name, reported, STATUS_FAILED, null) + } + if (!folder.mkdirs() && !folder.isDirectory) return fileOf(name, reported, STATUS_FAILED, null) + return try { + var total = 0L + var tooLarge = false + val input = contentResolver.openInputStream(uri) + ?: throw IOException("열 수 없음") + input.use { source -> + FileOutputStream(target).use { sink -> + val buffer = ByteArray(COPY_BUFFER_BYTES) + while (true) { + val read = source.read(buffer) + if (read < 0) break + total += read + if (total > maxBytes) { + tooLarge = true + break + } + sink.write(buffer, 0, read) + } + } + } + if (tooLarge) { + folder.deleteRecursively() + fileOf(name, reported, STATUS_TOO_LARGE, null) + } else { + fileOf(name, total, STATUS_OK, target.path) + } + } catch (error: Exception) { + // 읽기 · 쓰기 실패(클라우드 파일을 받지 못함 · 권한 · 저장 공간) — 쓰다 만 복사본을 지운다 + folder.deleteRecursively() + fileOf(name, reported, STATUS_FAILED, null) + } + } + + /** 복사본 하나를 그 폴더째 지운다 */ + private fun deleteCopy(path: String) { + File(path).parentFile?.deleteRecursively() + } + + private fun fileOf(name: String?, size: Long?, status: String, path: String?): Map = + mapOf(KEY_NAME to name, KEY_SIZE to size, KEY_STATUS to status, KEY_PATH to path) + + private fun outcomeOf(outcome: String): Map = mapOf(KEY_OUTCOME to outcome) + + /** 복사본마다 새 폴더 — 같은 이름을 여러 번 골라도 덮지 않는다(16진수 16자 — 지역 설정과 상관없이 0-9a-f) */ + private fun randomFolderName(): String { + val bytes = ByteArray(FOLDER_NAME_BYTES) + random.nextBytes(bytes) + val name = StringBuilder() + for (byte in bytes) { + val value = byte.toInt() and 0xff + name.append(HEX_DIGITS[value shr 4]).append(HEX_DIGITS[value and 0x0f]) + } + return name.toString() + } + + // ── 이름 · 확장자 (짝: Dart `core/files/file_names.dart` · `features/mail/domain/compose_attachments.dart`) ── + + /** + * 보이는 이름 — **짝: Dart `displayFileName`** 과 같은 규칙. 경로 앞부분(`/` · `\` 의 마지막 뒤)만 두고 제어 · 방향 제어 · 보이지 않는 글자를 지운 뒤 + * 앞뒤 공백(Dart `trim()` 의 공백 — [isDartWhitespace])을 지운다. 비면 [FALLBACK_FILE_NAME]. 형식을 가를 때만 쓴다(Dart 가 다시 정리한다) + */ + private fun displayFileName(raw: String?): String { + if (raw == null) return FALLBACK_FILE_NAME + val slash = maxOf(raw.lastIndexOf('/'), raw.lastIndexOf('\\')) + val segment = if (slash == -1) raw else raw.substring(slash + 1) + val cleaned = StringBuilder() + forEachCodePoint(segment) { codePoint -> + if (!isControl(codePoint) && !isInvisibleFormat(codePoint)) cleaned.appendCodePoint(codePoint) + } + val result = cleaned.toString().trim { isDartWhitespace(it.code) } + return result.ifEmpty { FALLBACK_FILE_NAME } + } + + /** 확장자 — **짝: Dart `extensionOf`**(마지막 점 뒤, 소문자 — 없으면 빈 글) */ + private fun extensionOf(name: String): String { + val dot = name.lastIndexOf('.') + return if (dot == -1) "" else name.substring(dot + 1).lowercase(Locale.ROOT) + } + + /** + * 기기 안 복사본의 이름 — 고른 앱이 정한 글이라 믿지 않는다. **짝: Dart `safeFileName`** 과 같은 규칙 + * (제어 · 방향 제어 · 보이지 않는 글자를 지우고, 경로 · 다른 시스템이 받지 않는 글자는 밑줄, 앞뒤 공백 · 점을 지우고, UTF-8 200바이트 — 확장자는 남긴다). + * 이 이름은 복사본에만 쓴다 — 보이는 이름 · 올릴 때 보내는 이름은 Dart 의 `displayFileName` + */ + private fun safeFileName(raw: String?): String { + val cleaned = StringBuilder() + forEachCodePoint(raw ?: "") { codePoint -> + if (isControl(codePoint) || isInvisibleFormat(codePoint)) return@forEachCodePoint + if (codePoint < 0x80 && REPLACED_CHARACTERS.indexOf(codePoint.toChar()) >= 0) { + cleaned.append('_') + } else { + cleaned.appendCodePoint(codePoint) + } + } + // Dart 의 trim() 뒤 `^[.\s]+|[.\s]+$` 지우기와 같다 — 앞뒤의 공백 · 점을 지운다 + val result = cleaned.toString().trim { it == '.' || isDartWhitespace(it.code) } + if (result.isEmpty()) return FALLBACK_FILE_NAME + return fitBytes(result, MAX_FILE_NAME_BYTES) + } + + private inline fun forEachCodePoint(text: String, action: (Int) -> Unit) { + var index = 0 + while (index < text.length) { + val codePoint = text.codePointAt(index) + index += Character.charCount(codePoint) + action(codePoint) + } + } + + /** 제어 문자(C0 · DEL · C1) — 짝: Dart `_isControl` */ + private fun isControl(codePoint: Int): Boolean = codePoint <= 0x1F || codePoint in 0x7F..0x9F + + /** 방향 제어 · 보이지 않는 글자 — 짝: Dart `_isInvisibleFormat` */ + private fun isInvisibleFormat(codePoint: Int): Boolean = + codePoint == 0x061C || + codePoint in 0x200B..0x200F || + codePoint in 0x202A..0x202E || + codePoint in 0x2060..0x2069 || + codePoint == 0xFEFF + + /** + * Dart `String.trim()` 이 지우는 공백(Unicode White_Space + BOM) — Kotlin 의 `trim()` 과 몇 글자가 달라 직접 적는다. + * Dart `RegExp` 의 `\s`(`safeFileName` 의 점 지우기)와도 제어 문자를 뺀 나머지가 같다 + */ + private fun isDartWhitespace(codePoint: Int): Boolean = + codePoint in 0x09..0x0D || + codePoint == 0x20 || + codePoint == 0x85 || + codePoint == 0xA0 || + codePoint == 0x1680 || + codePoint in 0x2000..0x200A || + codePoint == 0x2028 || + codePoint == 0x2029 || + codePoint == 0x202F || + codePoint == 0x205F || + codePoint == 0x3000 || + codePoint == 0xFEFF + + /** UTF-8 [maxBytes] 안으로 — 확장자(점 뒤 16자까지 영문 · 숫자)는 남기고 앞부분을 글자 단위로 자른다 */ + private fun fitBytes(name: String, maxBytes: Int): String { + if (name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name + val dot = name.lastIndexOf('.') + val extension = if (dot > 0 && + name.length - dot - 1 <= MAX_EXTENSION_LENGTH && + EXTENSION_PATTERN.matches(name.substring(dot + 1)) + ) { + name.substring(dot) + } else { + "" + } + val base = if (extension.isEmpty()) name else name.substring(0, dot) + val budget = maxBytes - extension.toByteArray(Charsets.UTF_8).size + val kept = StringBuilder() + var used = 0 + var index = 0 + while (index < base.length) { + val codePoint = base.codePointAt(index) + val size = String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8).size + if (used + size > budget) break + kept.appendCodePoint(codePoint) + used += size + index += Character.charCount(codePoint) + } + val trimmed = kept.toString().trimEnd() + return if (trimmed.isEmpty()) FALLBACK_FILE_NAME + extension else trimmed + extension + } + private companion object { - /** 짝: Dart `PlatformFileOpener.channel` · `openMethod` */ + /** 짝: Dart `PlatformFileOpener.channel` · `openMethod` · `PlatformPickedFileStore.pickMethod` */ const val FILES_CHANNEL = "kr.it.arasystem.app/files" const val OPEN_METHOD = "open" + const val PICK_METHOD = "pick" + + /** `pick` 의 인자 — 짝: Dart `PlatformPickedFileStore.pick` */ + const val ARG_MAX_BYTES = "maxBytes" + const val ARG_MAX_COUNT = "maxCount" + const val ARG_MAX_TOTAL_BYTES = "maxTotalBytes" + const val ARG_EXTENSIONS = "extensions" /** 짝: Dart `kDownloadedFilesFolder` · `res/xml/downloaded_file_paths.xml` 의 cache-path */ const val DOWNLOADS_DIR = "downloads" + /** 짝: Dart `kPickedFilesFolder` — 다른 앱에 건네지 않아 FileProvider 경로(xml)에는 없다 */ + const val UPLOADS_DIR = "uploads" + /** 짝: `AndroidManifest.xml` 의 provider authorities(`${applicationId}.downloads`) */ const val AUTHORITY_SUFFIX = ".downloads" @@ -90,5 +469,46 @@ class MainActivity : FlutterActivity() { const val RESULT_NO_APP = "noApp" const val RESULT_NOT_FOUND = "notFound" const val RESULT_FAILED = "failed" + + /** 고르는 창의 요청 번호 — 다른 플러그인의 번호와 겹치지 않게 앱 이름(AR)에서 */ + const val PICK_REQUEST_CODE = 0x4152 + + /** 고르는 창에 보일 형식 — 모두 */ + const val ANY_TYPE = "*/*" + + // 짝: Dart `PickResult.parse` · `PickedFile.fromChannel` 의 칸과 글 + const val KEY_OUTCOME = "outcome" + const val KEY_FILES = "files" + const val KEY_NAME = "name" + const val KEY_SIZE = "size" + const val KEY_STATUS = "status" + const val KEY_PATH = "path" + const val OUTCOME_PICKED = "picked" + const val OUTCOME_CANCELED = "canceled" + const val OUTCOME_BUSY = "busy" + const val OUTCOME_NO_APP = "noApp" + const val OUTCOME_FAILED = "failed" + const val STATUS_OK = "ok" + const val STATUS_SKIPPED = "skipped" + const val STATUS_NOT_ALLOWED = "notAllowed" + const val STATUS_TOO_LARGE = "tooLarge" + const val STATUS_OVER_TOTAL = "overTotal" + const val STATUS_FAILED = "failed" + + /** 복사할 때 한 번에 읽는 크기 */ + const val COPY_BUFFER_BYTES = 64 * 1024 + + /** 복사본마다 만드는 폴더 이름의 바이트(16진수 16자 — Dart 받은 파일 폴더와 같은 길이) */ + const val FOLDER_NAME_BYTES = 8 + const val HEX_DIGITS = "0123456789abcdef" + + // 짝: Dart `kFallbackFileName` · `kMaxFileNameBytes` · `_maxExtensionLength` · `_replacedCharacters`(`file_names.dart`) + const val FALLBACK_FILE_NAME = "첨부파일" + const val MAX_FILE_NAME_BYTES = 200 + const val MAX_EXTENSION_LENGTH = 16 + const val REPLACED_CHARACTERS = "/\\:*?\"<>|" + val EXTENSION_PATTERN = Regex("^[A-Za-z0-9]+$") + + val random = SecureRandom() } } diff --git a/flutter/lib/core/files/file_names.dart b/flutter/lib/core/files/file_names.dart index 946d263..eaa4954 100644 --- a/flutter/lib/core/files/file_names.dart +++ b/flutter/lib/core/files/file_names.dart @@ -1,4 +1,4 @@ -/// 받은 파일을 기기에 둘 때의 이름 — 순수 함수 (모듈 공통: 메일 첨부 · 나중의 파일 모듈). +/// 받은 파일을 기기에 둘 때의 이름 · 고른 파일의 보이는 이름 — 순수 함수 (모듈 공통: 메일 첨부 · 나중의 파일 모듈). /// /// 이름은 보낸 쪽이 정한 글이라 믿지 않는다 — 경로 문자 · 제어 문자 · 방향 제어 문자를 지우고 길이를 줄인다 /// (서버의 첨부 이름 정리 `attachment.policy.ts` 와 같은 결 — 확장자는 남긴다). 저장 경로는 앱이 만든 폴더 안 한 칸뿐이다. @@ -29,7 +29,7 @@ bool _isInvisibleFormat(int rune) => /// 밑줄로 바꾸는 글자 — 경로 구분 · 다른 시스템이 이름에 받지 않는 글자 const String _replacedCharacters = r'/\:*?"<>|'; -/// 기기에 둘 파일 이름 — 비거나 `.` · `..` 뿐이면 [fallback] +/// 기기에 둘 파일 이름 — 비거나 `.` · `..` 뿐이면 [fallback]. **짝: `MainActivity.kt` 의 `safeFileName`**(고른 파일의 복사본 이름) String safeFileName(String name, {String fallback = kFallbackFileName}) { final StringBuffer cleaned = StringBuffer(); for (final int rune in name.runes) { @@ -44,6 +44,25 @@ String safeFileName(String name, {String fallback = kFallbackFileName}) { return _fitBytes(result, kMaxFileNameBytes); } +/// 고른 파일의 보이는 이름 — 고르는 창의 앱이 알려 준 이름(`OpenableColumns.DISPLAY_NAME`)에서 경로 앞부분 · 제어 문자 · +/// 방향 제어 · 보이지 않는 글자를 지우고 앞뒤 공백을 지운다. 비면 [kFallbackFileName]. +/// +/// 다른 글자(`:` 등)와 길이는 그대로 둔다 — 서버가 받은 이름을 같은 결로 다시 정리한다(`shared/utils/file-name.ts` 의 `sanitizeFileName` — +/// 웹이 보내는 파일 이름과 같게). 기기 안 복사본의 이름은 따로 정리한다([safeFileName] · `MainActivity.kt` 의 `safeFileName`). +/// **짝: `MainActivity.kt` 의 `displayFileName`** — 고를 때 기기가 이 이름의 확장자로 형식을 미리 거른다(공백은 Dart `trim()` 의 공백) +String displayFileName(String? name) { + if (name == null) return kFallbackFileName; + final int slash = name.lastIndexOf(RegExp(r'[/\\]')); + final String segment = slash == -1 ? name : name.substring(slash + 1); + final StringBuffer cleaned = StringBuffer(); + for (final int rune in segment.runes) { + if (_isControl(rune) || _isInvisibleFormat(rune)) continue; + cleaned.writeCharCode(rune); + } + final String result = cleaned.toString().trim(); + return result.isEmpty ? kFallbackFileName : result; +} + /// UTF-8 [maxBytes] 안으로 줄인다 — 확장자(점 뒤 짧은 영문 · 숫자)는 남기고 앞부분을 글자 단위로 자른다 String _fitBytes(String name, int maxBytes) { if (utf8.encode(name).length <= maxBytes) return name; diff --git a/flutter/lib/core/files/picked_files.dart b/flutter/lib/core/files/picked_files.dart new file mode 100644 index 0000000..498650c --- /dev/null +++ b/flutter/lib/core/files/picked_files.dart @@ -0,0 +1,330 @@ +/// 고른 파일 — 안드로이드 고르는 창(`ACTION_GET_CONTENT`)으로 고른 파일을 앱 임시 폴더에 복사해 두고, 거기서 읽어 올린다(모듈 공통: 메일 첨부 · 나중의 파일 모듈). +/// +/// 패키지를 더하지 않고 앱 안에 둔 까닭(받은 파일 열기와 같다 — `downloaded_files.dart`): 파일 고르는 플러그인은 저장소 · 사진 권한이나 +/// 기기 전체를 가리키는 경로를 더한다. 이 길은 권한 없이 고른 파일만 읽는다(고르는 창이 그 파일의 읽기 권한을 준다 — `content://` 만 읽는다). +/// 고르는 창에는 모든 파일을 보인다(`*/*` — hwp 는 기기마다 형식이 달라 창에서 거르면 숨는다). +/// 고른 뒤 기기가 넘겨받은 한도(남은 개수 · 허용 확장자 · 파일당 · 남은 합계)로 쓰는 쪽의 사전 검사와 **같은 차례**로 미리 걸러 넣을 파일만 복사한다 — +/// 받지 않을 형식 · 합계를 넘는 파일을 내려받거나 쌓아 두지 않게. 판정은 쓰는 쪽(Dart)이 다시 한다. +/// +/// 복사본을 두는 까닭: 고른 주소(content://)의 읽기 권한은 그 화면(Activity)이 있는 동안뿐이고, 클라우드 파일은 읽을 때마다 받는다. +/// 복사해 두면 올리기에 실패해도 [다시 시도] · 서버가 찾지 못한 첨부(`MAIL_004`)를 다시 고르지 않고 올린다. +/// +/// 정리하는 때: 첨부를 뺄 때 · 쓰기 화면이 끝날 때(그 화면의 복사본) · 앱을 띄울 때(main — 지난 실행이 남긴 것) · +/// 메일을 보는 직원이 바뀔 때(모두) · 기기가 저장 공간이 모자랄 때(안드로이드가 임시 폴더를 비운다 — 그래서 올리기 전에 복사본이 있는지 본다). +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/core/files/file_names.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; + +/// 고르기 한 번을 기다리는 가장 긴 시간 — 고르는 창을 연 때부터 고른 파일을 다 가져올 때까지(창에 머문 시간도 든다). +/// 파일당 20MB · 합계 25MB 를 느린 망에서 클라우드 파일로 받아도 넉넉하게 10분. 넘으면 그 고르기를 [PickOutcome.timedOut] 으로 끝내고 +/// 차례를 넘긴다 — 고른 앱(provider)이 읽기에서 멈춰 기기가 답하지 않을 때 [보내기] · [파일 첨부] · 다음 고르기 · 직원이 바뀔 때의 정리가 +/// 그 뒤에 묶이지 않게. 늦게 온 기기의 답은 버린다 — 그 복사본은 다음 정리(앱을 띄울 때 · 직원이 바뀔 때)에 지워진다 +const Duration kPickTimeout = Duration(minutes: 10); + +/// 고른 파일을 복사해 두는 폴더 이름 — 앱 임시 폴더(Android `cacheDir`) 아래. +/// **짝: `MainActivity.kt` 의 `UPLOADS_DIR`** — 다른 앱에 건네지 않으므로 FileProvider 경로(`downloaded_file_paths.xml`)에는 넣지 않는다 +const String kPickedFilesFolder = 'uploads'; + +/// 고른 파일 하나를 가져온 결과 (짝: `MainActivity.kt` 의 `STATUS_*` — 기기가 거른 차례: 개수 → 형식 → 파일당 → 합계) +enum PickedFileStatus { + /// 다 복사했다 — [PickedFile.path] 가 복사본 + ok, + + /// 넣을 파일이 `maxCount` 개가 되어 가져오지 않았다 + skipped, + + /// 허용 확장자(`extensions`)가 아니다 — 읽지 않았다 + notAllowed, + + /// 한도(`maxBytes`)보다 크다 — 복사하지 않았다(고른 앱이 알려 준 크기로, 또는 한도 + 1바이트를 읽고 멈췄다) + tooLarge, + + /// 넣으면 남은 합계(`maxTotalBytes`)를 넘는다 — 알려 준 크기로 읽지 않았거나, 복사한 크기로 보고 복사본을 지웠다 + overTotal, + + /// 읽지 못했다(클라우드 파일을 받지 못함 · 권한 · 저장 공간 · `content://` 가 아닌 주소) + failed, +} + +/// 고른 파일 하나 +@immutable +class PickedFile { + const PickedFile({ + required this.name, + required this.status, + this.size, + this.path, + }); + + /// 보이는 이름 — [displayFileName] 으로 정리했다(올릴 때 이 이름을 보낸다) + final String name; + final PickedFileStatus status; + + /// [PickedFileStatus.ok] 면 복사한 바이트, 그 밖은 고른 앱이 알려 준 크기(모르면 null) + final int? size; + + /// 복사본의 경로 — [PickedFileStatus.ok] 일 때만(로그 · 알림에 싣지 않는다) + final String? path; + + /// 채널이 돌려준 항목 하나 — 모양이 틀리면 읽지 못한 파일로 본다(알리기는 한다) + static PickedFile fromChannel(Object? raw) { + if (raw is! Map) { + return const PickedFile( + name: kFallbackFileName, + status: PickedFileStatus.failed, + ); + } + final Object? name = raw['name']; + final Object? size = raw['size']; + final Object? path = raw['path']; + final PickedFileStatus? status = switch (raw['status']) { + 'ok' => PickedFileStatus.ok, + 'skipped' => PickedFileStatus.skipped, + 'notAllowed' => PickedFileStatus.notAllowed, + 'tooLarge' => PickedFileStatus.tooLarge, + 'overTotal' => PickedFileStatus.overTotal, + 'failed' => PickedFileStatus.failed, + _ => null, + }; + final String shown = displayFileName(name is String ? name : null); + final int? bytes = size is int && size >= 0 ? size : null; + if (status == PickedFileStatus.ok) { + // 다 복사했다는데 경로 · 크기가 없으면 쓸 수 없다 + if (path is! String || path.isEmpty || bytes == null) { + return PickedFile(name: shown, status: PickedFileStatus.failed); + } + return PickedFile( + name: shown, + status: PickedFileStatus.ok, + size: bytes, + path: path, + ); + } + return PickedFile( + name: shown, + status: status ?? PickedFileStatus.failed, + size: bytes, + ); + } + + @override + bool operator ==(Object other) => + other is PickedFile && + other.name == name && + other.status == status && + other.size == size && + other.path == path; + + @override + int get hashCode => Object.hash(name, status, size, path); +} + +/// 고르기 한 번의 결과 (짝: `MainActivity.kt` 의 `OUTCOME_*`) +enum PickOutcome { + /// 골랐다 — [PickResult.files] + picked, + + /// 고르지 않고 닫았다 + canceled, + + /// 고르는 창이 이미 열려 있다 + busy, + + /// 고르는 창을 열 앱이 없다 + noApp, + + /// 그 밖의 실패(채널이 없음 — iOS 는 아직 없다 · 모르는 답) + failed, + + /// [kPickTimeout] 안에 기기가 답하지 않았다 — 고른 파일을 가져오지 못한 것으로 본다(앱에서만 — 기기는 보내지 않는다) + timedOut, +} + +/// 고르기 한 번의 결과 +@immutable +class PickResult { + const PickResult(this.outcome, [this.files = const []]); + + final PickOutcome outcome; + + /// 고른 차례대로 — [PickOutcome.picked] 일 때만 + final List files; + + /// 채널의 답 — 모양이 틀리면 실패 + static PickResult parse(Object? raw) { + if (raw is! Map) { + return const PickResult(PickOutcome.failed); + } + final PickOutcome? outcome = switch (raw['outcome']) { + 'picked' => PickOutcome.picked, + 'canceled' => PickOutcome.canceled, + 'busy' => PickOutcome.busy, + 'noApp' => PickOutcome.noApp, + 'failed' => PickOutcome.failed, + _ => null, + }; + if (outcome == null) return const PickResult(PickOutcome.failed); + if (outcome != PickOutcome.picked) return PickResult(outcome); + final Object? files = raw['files']; + if (files is! List) return const PickResult(PickOutcome.failed); + return PickResult( + PickOutcome.picked, + List.unmodifiable([ + for (final Object? item in files) PickedFile.fromChannel(item), + ]), + ); + } +} + +/// 고른 파일을 가져오고 복사본을 다루는 곳 — 시험은 덮어쓴다(시험 시계 안에서는 기기 파일 입출력이 끝나지 않는다) +abstract class PickedFileStore { + /// 고르는 창을 열고 고른 파일 가운데 넣을 파일만 복사해 둔다 — 고른 차례대로, 쓰는 쪽 사전 검사와 같은 차례로 거른다: + /// 넣을 파일이 [maxCount] 개가 되면 나머지는 가져오지 않고([PickedFileStatus.skipped]), 확장자가 [extensions] 에 없으면 읽지 않고 + /// ([PickedFileStatus.notAllowed]), [maxBytes] + 1바이트를 넘으면 복사를 멈추고([PickedFileStatus.tooLarge]), + /// 넣은 파일의 합이 [maxTotalBytes] 를 넘게 되면 넣지 않는다([PickedFileStatus.overTotal]). 거른 파일은 개수 · 합계에 들지 않는다 + Future pick({ + required int maxBytes, + required int maxCount, + required int maxTotalBytes, + required List extensions, + }); + + /// 복사본이 아직 있는가 — 복사본 폴더 밖의 경로는 거짓(기기가 임시 폴더를 비웠을 수 있다) + Future exists(String path); + + /// 복사본을 읽는다(올리기 — 부를 때마다 처음부터) + Stream> openRead(String path); + + /// 복사본 하나를 그 폴더째 지운다 — 복사본 폴더 밖은 지우지 않는다 + Future delete(String path); + + /// 모두 지운다 + Future purge(); +} + +/// 고른 파일을 두는 곳 — main 이 앱을 띄울 때 정리한 것으로 덮어쓴다 +final Provider pickedFileStoreProvider = + Provider((Ref ref) => PlatformPickedFileStore()); + +/// 안드로이드 고르는 창(`MainActivity.kt` 의 파일 채널 `pick`) + 앱 임시 폴더의 [kPickedFilesFolder]. +/// +/// 고르기와 모두 지우기는 부른 차례대로 하나씩 한다 — 고르는 동안 직원이 바뀌어 지우기가 들어오면 고른 복사본까지 지운다 +/// (앱을 띄울 때의 정리가 막 고른 복사본을 지우지 않게도). +class PlatformPickedFileStore implements PickedFileStore { + PlatformPickedFileStore({ + Future Function() baseDirectory = getTemporaryDirectory, + MethodChannel channel = PlatformFileOpener.channel, + Duration pickTimeout = kPickTimeout, + }) : _baseDirectory = baseDirectory, + _channel = channel, + _pickTimeout = pickTimeout; + + /// 짝: `MainActivity.kt` 의 `PICK_METHOD` (채널은 받은 파일 열기와 같다 — `PlatformFileOpener.channel`) + static const String pickMethod = 'pick'; + + final Future Function() _baseDirectory; + final MethodChannel _channel; + + /// 고르기 한 번의 시한 — [kPickTimeout](시험은 짧게) + final Duration _pickTimeout; + Future _queue = Future.value(); + + Future _serial(Future Function() task) { + final Future next = _queue.then((_) => task()); + _queue = next.then((_) {}, onError: (Object _) {}); + return next; + } + + /// 복사본 폴더 — 임시 폴더를 알 수 없으면(플러그인 · 기기 사정) null + Future _root() async { + try { + final Directory base = await _baseDirectory(); + return Directory('${base.path}/$kPickedFilesFolder'); + } on Exception { + return null; + } + } + + @override + Future pick({ + required int maxBytes, + required int maxCount, + required int maxTotalBytes, + required List extensions, + }) => _serial(() async { + try { + // 인자 이름 — 짝: `MainActivity.kt` 의 `ARG_*`. 시한은 차례 안에서 잰다(넘으면 다음 차례로) + final Object? raw = await _channel + .invokeMethod(pickMethod, { + 'maxBytes': maxBytes, + 'maxCount': maxCount, + 'maxTotalBytes': maxTotalBytes, + 'extensions': extensions, + }) + .timeout(_pickTimeout); + return PickResult.parse(raw); + } on TimeoutException { + // 기기가 답하지 않는다(고른 앱이 읽기에서 멈춤 등) — 이 고르기를 끝내고 차례를 넘긴다. + // 늦게 온 답은 버린다 — 그 복사본은 다음 정리(앱을 띄울 때 · 직원이 바뀔 때)에 지워진다 + return const PickResult(PickOutcome.timedOut); + } on PlatformException { + return const PickResult(PickOutcome.failed); + } on MissingPluginException { + return const PickResult(PickOutcome.failed); + } + }); + + /// 복사본의 폴더 — 복사본 폴더 바로 아래 한 칸(무작위 폴더)일 때만. 기호 링크를 풀어 견준다(Kotlin 이 canonical 로 만든 자리). + /// 없거나 밖이면 null + Future _copyFolderOf(String path) async { + final Directory? root = await _root(); + if (root == null) return null; + try { + final String rootPath = await root.resolveSymbolicLinks(); + final File file = File(await File(path).resolveSymbolicLinks()); + final Directory folder = file.parent; + return folder.parent.path == rootPath ? folder : null; + } on FileSystemException { + return null; + } + } + + @override + Future exists(String path) async { + if (await _copyFolderOf(path) == null) return false; + return File(path).exists(); + } + + @override + Stream> openRead(String path) => File(path).openRead(); + + @override + Future delete(String path) async { + final Directory? folder = await _copyFolderOf(path); + if (folder == null) return; + try { + await folder.delete(recursive: true); + } on FileSystemException { + // 이미 없다 — 다음 정리 · 기기가 지운다 + } + } + + @override + Future purge() => _serial(() async { + final Directory? root = await _root(); + if (root == null) return; + try { + if (await root.exists()) await root.delete(recursive: true); + } on FileSystemException { + // 다음 정리 · 기기가 지운다 + } + }); +} diff --git a/flutter/lib/core/utils/file_size.dart b/flutter/lib/core/utils/file_size.dart index 1652191..2ba80d3 100644 --- a/flutter/lib/core/utils/file_size.dart +++ b/flutter/lib/core/utils/file_size.dart @@ -1,6 +1,7 @@ -/// 파일 크기 표기 — 웹 `shared/utils/format.ts` 의 `formatFileSize` 와 같은 글(순수 함수). +/// 파일 크기 표기 — 웹 `shared/utils/format.ts` 의 `formatFileSize` · `formatMegabytes` 와 같은 글(순수 함수). /// /// "248 KB" · "1.2 MB" · "38 MB" — 1KB 미만은 바이트 그대로, 그 위는 KB · MB · GB · TB. KB 는 정수, MB 이상은 10 미만일 때 소수 한 자리. +/// 한도 글은 "20MB"(붙여 씀 — [formatMegabytes]). library; const int _bytesPerUnit = 1024; @@ -24,6 +25,16 @@ String formatFileSize(int bytes) { return '$shown ${_units[unit]}'; } +/// 한도 글의 크기 — "20MB" · "25MB" · "20.5MB" (웹 `format.ts` 의 `formatMegabytes` 와 같은 글 — 정수 MB 면 그대로, 아니면 소수 한 자리). +/// 첨부 한도 안내 · 한도를 넘은 알림에 쓴다(서버가 알려 준 바이트 — `GET /api/mail-quota`) +String formatMegabytes(int bytes) { + final double megabytes = bytes / (_bytesPerUnit * _bytesPerUnit); + final String shown = megabytes == megabytes.roundToDouble() + ? megabytes.round().toString() + : _oneDecimal(megabytes); + return '${shown}MB'; +} + /// 소수 한 자리 — 끝의 ".0" 은 뺀다(웹 `Math.round(value * 10) / 10` 이 내는 글과 같다) String _oneDecimal(double value) { final double rounded = (value * 10).round() / 10; diff --git a/flutter/lib/core/utils/korean_text.dart b/flutter/lib/core/utils/korean_text.dart index 072f886..41a7c68 100644 --- a/flutter/lib/core/utils/korean_text.dart +++ b/flutter/lib/core/utils/korean_text.dart @@ -1,5 +1,5 @@ /// 한국어 글 다듬기 — 줄 나눔(웹 `word-break: keep-all` — 앱 보드 `App1Login` 의 안내 글)과 -/// 이름 뒤의 조사(받침에 맞춰 "이/가" · "으로/로" — 웹 `shared/utils/korean.ts` 와 같다). +/// 이름 뒤의 조사(받침에 맞춰 "이/가" · "으로/로" — 웹 `shared/utils/korean.ts` 와 같다. "은/는" 은 앱에서 더했다). library; /// WORD JOINER — 보이지 않고, 그 앞뒤에서 줄을 나누지 못하게 한다(유니코드 줄 나눔 규칙 WJ) @@ -64,6 +64,15 @@ String directionParticle(String word) { return jongseong == _noJongseong || jongseong == _jongseongRieul ? '로' : '으로'; } +/// 보조사 "은/는" — 받침이 있으면 "은", 없으면 "는"(사진은 · 보고서는 · 외 2개는). +/// 한글로 끝나지 않는 이름(영문 · 숫자 — 확장자로 끝나는 파일 이름)은 소리를 알 수 없어 "은(는)" 이다 +/// (앱에서 더함 — 웹 `korean.ts` 에는 없다. [subjectParticle] 과 같은 규칙) +String topicParticle(String word) { + final int? jongseong = _lastJongseong(word); + if (jongseong == null) return '은(는)'; + return jongseong == _noJongseong ? '는' : '은'; +} + /// 띄어쓰기 · 제어 문자를 뺀 ASCII 글자 — 영문 · 숫자 · 기호 bool _isAsciiGraphic(int rune) => rune >= 0x21 && rune <= 0x7E; diff --git a/flutter/lib/features/mail/data/models/compose_models.dart b/flutter/lib/features/mail/data/models/compose_models.dart index 6b0cf63..aecf771 100644 --- a/flutter/lib/features/mail/data/models/compose_models.dart +++ b/flutter/lib/features/mail/data/models/compose_models.dart @@ -1,4 +1,4 @@ -/// 쓰기 화면의 응답 모양 — 계약 2절 `Draft` · `DraftSaved` · `SentMessage` · `MailSignature` · `MailSettings`, 4절 `LargeFile` · `MissingLargeFile`. +/// 쓰기 화면의 응답 모양 — 계약 2절 `Draft` · `DraftSaved` · `SentMessage` · `MailSignature` · `MailSettings` · `MailQuota`(첨부 한도), 4절 `LargeFile` · `MissingLargeFile`. /// /// 모델은 손으로 쓴다(flutter 스킬 "모델 생성기"). 모양이 틀리면 [tryParse] 가 null — 저장소가 `SYS_001` 로 던진다. /// 나중 단계에 더해진 칸(`largeFiles` · `pendingSend` · `rejectedRecipients`)은 없으면 빈 목록 · null 로 읽는다(웹과 같다). @@ -331,3 +331,42 @@ int? undoSendSecondsOf(Object? json) { if (seconds is! num || seconds < 0) return null; return seconds.toInt(); } + +/// 첨부 한도 — `GET /api/mail-quota` 의 `attachmentMaxBytes` · `attachmentTotalMaxBytes` (계약 2절 `MailQuota` — 사용량 칸은 쓰지 않는다). +/// 받지 못하면 대체값(20 · 25MB — `kFallbackAttachmentLimits`)을 쓴다 +@immutable +class AttachmentLimits { + const AttachmentLimits({required this.maxBytes, required this.totalMaxBytes}); + + /// 파일 하나의 한도 — 새로 올리는 파일만 본다(전달하는 원본은 서버도 합계만 본다) + final int maxBytes; + + /// 메일 한 통의 첨부 합계 한도 — 전달하는 원본 · 저장본의 첨부까지 함께 센다 + final int totalMaxBytes; + + /// 모양이 틀리면(없음 · 0 이하) null + static AttachmentLimits? tryParse(Object? json) { + if (json is! Map) return null; + final Object? maxBytes = json['attachmentMaxBytes']; + final Object? totalMaxBytes = json['attachmentTotalMaxBytes']; + if (maxBytes is! num || + totalMaxBytes is! num || + maxBytes <= 0 || + totalMaxBytes <= 0) { + return null; + } + return AttachmentLimits( + maxBytes: maxBytes.toInt(), + totalMaxBytes: totalMaxBytes.toInt(), + ); + } + + @override + bool operator ==(Object other) => + other is AttachmentLimits && + other.maxBytes == maxBytes && + other.totalMaxBytes == totalMaxBytes; + + @override + int get hashCode => Object.hash(maxBytes, totalMaxBytes); +} diff --git a/flutter/lib/features/mail/data/repositories/compose_repository.dart b/flutter/lib/features/mail/data/repositories/compose_repository.dart index 7bba60d..ce478c7 100644 --- a/flutter/lib/features/mail/data/repositories/compose_repository.dart +++ b/flutter/lib/features/mail/data/repositories/compose_repository.dart @@ -7,6 +7,7 @@ import 'package:arasystem_app/core/people/recipient_suggestion.dart'; import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; import 'package:arasystem_app/features/mail/data/models/compose_payload.dart'; import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; import 'package:dio/dio.dart'; @@ -33,6 +34,12 @@ abstract final class ComposeApiPaths { /// 대용량 파일 하나의 상태 — 발송이 `MAIL_016` 이면 파일마다 다시 묻는다(계약 4절) static String fileUpload(String id) => '/file-uploads/${Uri.encodeComponent(id)}'; + + /// 첨부 올리기 — multipart `file` 하나(계약 2절 `POST /api/mail-attachments`) + static const String mailAttachments = '/mail-attachments'; + + /// 메일 사용량 · 첨부 한도 + static const String mailQuota = '/mail-quota'; } /// 발송 · 저장의 받기 한도 — 서버가 55~56초 안에 답한다(계약 2절). 기본 한도(15초)로 끊으면 화면은 실패로 보는데 서버는 이미 보냈을 수 있다 @@ -42,6 +49,15 @@ const Duration kMailSendTimeout = Duration(seconds: 60); /// 중복 발송 방지 키 머리 (계약 2절) const String kIdempotencyHeader = 'Idempotency-Key'; +/// 첨부 올리기의 보내기 한도 — 본문 전체에 건다(Dio 5.9 는 sendTimeout 을 본문을 다 보낼 때까지 잰다). 20MB 를 느린 망에서도 +const Duration kAttachmentUploadSendTimeout = Duration(minutes: 5); + +/// 첨부 올리기의 받기 한도 — 다 보낸 뒤 서버가 메일 엔진에 넘기고 답한다(발송과 같은 60초) +const Duration kAttachmentUploadReceiveTimeout = Duration(seconds: 60); + +/// 첨부 올리기의 multipart 칸 이름 (백엔드 `FileInterceptor('file')`) +const String kAttachmentFileField = 'file'; + /// 처음 받아 두는 추천 수 — 계약의 최대 limit (웹 `RECIPIENT_SUGGESTION_PRELOAD`) const int kRecipientSuggestionPreload = 100; @@ -57,7 +73,7 @@ typedef RecipientSuggestionPage = ({ bool hasNext, }); -/// 쓰기 화면의 API — 계약 2절(임시 저장 · 발송 · 서명 · 받는 사람 추천 · 보내기 설정) · 3절(그룹 · 구성원) · 4절(대용량 파일 상태). +/// 쓰기 화면의 API — 계약 2절(임시 저장 · 발송 · 첨부 올리기 · 첨부 한도 · 서명 · 받는 사람 추천 · 보내기 설정) · 3절(그룹 · 구성원) · 4절(대용량 파일 상태). /// /// 응답 언래핑 · 에러 코드 · 알림은 인터셉터가 맡는다 — 여기서는 읽기만 하고 문구를 만들지 않는다. /// 실패는 늘 `DioException`(`error` 에 `ApiException`)이다 — 응답 모양이 계약과 다르면 `SYS_001`. @@ -249,6 +265,58 @@ class ComposeRepository { return file; } + /// 첨부 하나를 올린다 — multipart `file`, 형식은 확장자의 정식 형식([type] — 서버가 확장자와 형식을 함께 본다), 이름은 고른 파일의 보이는 이름. + /// [read] 는 부를 때마다 처음부터 읽는다(401 뒤 갱신해 다시 보낼 때 — 인증 인터셉터가 FormData 를 복제한다). + /// 실패는 화면이 그 줄에 사전 문구로 보인다 — 알리지 않는다 + Future uploadAttachment({ + required String name, + required String type, + required int length, + required Stream> Function() read, + required CancelToken cancelToken, + required void Function(int sent, int total) onProgress, + }) async { + final FormData form = FormData() + ..files.add( + MapEntry( + kAttachmentFileField, + MultipartFile.fromStream( + read, + length, + filename: name, + contentType: DioMediaType.parse(type), + ), + ), + ); + final Response response = await _dio.post( + ComposeApiPaths.mailAttachments, + data: form, + options: Options( + sendTimeout: kAttachmentUploadSendTimeout, + receiveTimeout: kAttachmentUploadReceiveTimeout, + extra: {kSilentExtraKey: true}, + ), + cancelToken: cancelToken, + onSendProgress: onProgress, + ); + final MessageAttachment? attachment = MessageAttachment.tryParse( + response.data, + ); + if (attachment == null) throw _malformed(response); + return attachment; + } + + /// 첨부 한도(파일당 · 합계) — 알리지 않는다(받지 못하면 대체값으로 쓴다) + Future getAttachmentLimits() async { + final Response response = await _dio.get( + ComposeApiPaths.mailQuota, + options: ApiOptions.silent(), + ); + final AttachmentLimits? limits = AttachmentLimits.tryParse(response.data); + if (limits == null) throw _malformed(response); + return limits; + } + Options _longOptions({required bool silent}) => Options( receiveTimeout: kMailSendTimeout, extra: {if (silent) kSilentExtraKey: true}, diff --git a/flutter/lib/features/mail/domain/compose_attachments.dart b/flutter/lib/features/mail/domain/compose_attachments.dart new file mode 100644 index 0000000..8e5d830 --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_attachments.dart @@ -0,0 +1,136 @@ +/// 첨부의 규칙 — 형식 표 · 한도 · 사전 검사 (순수 함수, 계약 2절 "첨부 업로드 한도"). +/// +/// 짝: 백엔드 `modules/mail/attachment.policy.ts` 의 `ATTACHMENT_TYPES`(확장자 → 정식 형식 — 시험이 그 파일과 맞춰 본다) · +/// `MAX_ATTACHMENTS_PER_MESSAGE`, 웹 `shared/utils/mail-attachment.ts`(`precheckAttachment` — 개수 → 형식 → 파일당 → 합계 차례) · +/// `shared/constants/mail.ts`(대체 한도). 한쪽을 고치면 둘 다. +library; + +import 'package:arasystem_app/core/files/picked_files.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; + +/// 메일 한 통의 첨부 개수 한도 — 올린 파일과 전달하는 원본을 함께 센다 (백엔드 `MAX_ATTACHMENTS_PER_MESSAGE` · 웹 `MAIL_MAX_ATTACHMENTS`) +const int kMailMaxAttachments = 20; + +/// 파일당 크기 대체값 — 서버 기본값과 같다(`GET /api/mail-quota` 를 받지 못했을 때 — 웹 `MAIL_ATTACHMENT_FALLBACK_MAX_BYTES`) +const int kMailAttachmentMaxBytes = 20 * 1024 * 1024; + +/// 첨부 합계 대체값 — 서버 기본값과 같다(웹 `MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES`) +const int kMailAttachmentTotalMaxBytes = 25 * 1024 * 1024; + +/// 한도를 받지 못했을 때 쓰는 값 +const AttachmentLimits kFallbackAttachmentLimits = AttachmentLimits( + maxBytes: kMailAttachmentMaxBytes, + totalMaxBytes: kMailAttachmentTotalMaxBytes, +); + +/// 허용 형식 — 확장자(소문자, 점 없이) → 올릴 때 보내는 정식 형식. +/// +/// 서버는 올린 파일의 확장자와 형식을 **둘 다** 본다(multer `isAllowedAttachment` — 그 확장자가 받는 형식이어야 한다). +/// 안드로이드가 알려 주는 형식은 기기 · 앱마다 달라(hwp 가 octet-stream 이거나 한컴 형식) 쓰지 않고, 확장자의 정식 형식을 보낸다. +/// **짝: 백엔드 `ATTACHMENT_TYPES` 의 `type`** (`compose_attachments_test.dart` 가 그 파일을 읽어 맞춰 본다) +const Map kMailAttachmentTypes = { + 'pdf': 'application/pdf', + 'doc': 'application/msword', + 'docx': + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls': 'application/vnd.ms-excel', + 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt': 'application/vnd.ms-powerpoint', + 'pptx': + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'hwp': 'application/x-hwp', + 'hwpx': 'application/hwp+zip', + 'txt': 'text/plain', + 'csv': 'text/csv', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'zip': 'application/zip', +}; + +/// 확장자 — 소문자, 없으면 빈 글 (웹 `extensionOf` 와 같다 — 마지막 점 뒤). +/// **짝: `MainActivity.kt` 의 `extensionOf`** — 고를 때 기기가 같은 규칙으로 형식을 미리 거른다(보이는 이름 `displayFileName` 도 짝) +String extensionOf(String name) { + final int dot = name.lastIndexOf('.'); + return dot == -1 ? '' : name.substring(dot + 1).toLowerCase(); +} + +/// 올릴 때 보내는 형식 — 허용 목록에 없으면(HEIC 등) null +String? attachmentUploadTypeOf(String name) => + kMailAttachmentTypes[extensionOf(name)]; + +/// 그림 첨부인가 — 줄 아이콘(보드: 그림은 그림 아이콘, 그 밖은 문서 아이콘) +bool isImageAttachment(String name) => + attachmentUploadTypeOf(name)?.startsWith('image/') ?? false; + +/// 이미 붙은 첨부(전달하는 원본 · 저장본의 첨부)를 보낼 수 있는가 — 개수(`MAIL_006`) → 형식(`FILE_001`) → 합계(`FILE_002`) 차례(서버와 같다). +/// 전달하는 원본 첨부는 파일당 한도를 보지 않는다(서버도 확장자와 합계만 본다). 걸리면 그 코드, 아니면 null +String? precheckCarriedAttachment({ + required String name, + required int size, + required int attachedCount, + required int attachedBytes, + int totalMaxBytes = kMailAttachmentTotalMaxBytes, +}) { + if (attachedCount + 1 > kMailMaxAttachments) return 'MAIL_006'; + if (attachmentUploadTypeOf(name) == null) return 'FILE_001'; + if (attachedBytes + size > totalMaxBytes) return 'FILE_002'; + return null; +} + +/// 고른 파일을 넣지 못한 까닭 +enum AttachmentRejection { + /// 개수 한도(`MAIL_006`) — 복사본 수 한도로 가져오지 않은 파일도 + tooMany, + + /// 허용하지 않는 형식(`FILE_001`) — HEIC 도 + notAllowed, + + /// 파일당 한도보다 크다 — 앱은 아직 대용량 첨부(링크)로 보내지 못한다 + tooLarge, + + /// 고른 파일을 읽지 못했다 + unreadable, + + /// 합계 한도를 넘게 만든다 + overTotal, +} + +/// 고른 파일을 붙이기 전 사전 검사 — 걸리면 붙이지 않는다. 웹 `precheckAttachment` 와 같은 차례: +/// 1) 개수(서버도 형식보다 먼저 본다 — 허용 밖 형식이어도 한도를 넘으면 개수) 2) 형식 3) 파일당 크기 4) (앱에서 더함) 읽지 못함 5) 합계 크기. +/// [attachedCount] · [attachedBytes] 는 이미 붙은 것(올리는 중 · 올림 · 전달 원본) — 앞에서 넣은 파일이 늘린다. +/// +/// 기기(`MainActivity.kt` 의 `copyPicked`)가 같은 차례로 미리 걸러 넘긴 까닭(`skipped` · `notAllowed` · `tooLarge` · `overTotal`)은 +/// 그 차례의 까닭으로 옮기고, 다 복사한 파일(`ok`)은 여기서 다시 판정한다(기기는 미리 거르기만 한다) +AttachmentRejection? precheckPickedFile( + PickedFile file, { + required int attachedCount, + required int attachedBytes, + required AttachmentLimits limits, +}) { + if (file.status == PickedFileStatus.skipped || + attachedCount + 1 > kMailMaxAttachments) { + return AttachmentRejection.tooMany; + } + if (file.status == PickedFileStatus.notAllowed || + attachmentUploadTypeOf(file.name) == null) { + return AttachmentRejection.notAllowed; + } + final int? size = file.size; + if (file.status == PickedFileStatus.tooLarge || + (size != null && size > limits.maxBytes)) { + return AttachmentRejection.tooLarge; + } + if (file.status == PickedFileStatus.overTotal) { + return AttachmentRejection.overTotal; + } + if (file.status != PickedFileStatus.ok || size == null) { + return AttachmentRejection.unreadable; + } + if (attachedBytes + size > limits.totalMaxBytes) { + return AttachmentRejection.overTotal; + } + return null; +} diff --git a/flutter/lib/features/mail/domain/compose_send.dart b/flutter/lib/features/mail/domain/compose_send.dart index e8abb96..9209b01 100644 --- a/flutter/lib/features/mail/domain/compose_send.dart +++ b/flutter/lib/features/mail/domain/compose_send.dart @@ -1,7 +1,8 @@ -/// 보내기의 규칙 — 중복 발송 방지 키 · 받지 못한 사람 문장 · 보내기 취소 알림의 마감 · 첨부 사전 검사 (순수 함수). +/// 보내기의 규칙 — 중복 발송 방지 키 · 받지 못한 사람 문장 · 보내기 취소 알림의 마감 · 대용량 파일 덩이 몫 (순수 함수). +/// 첨부의 형식 · 한도 · 사전 검사는 `compose_attachments.dart`. /// /// 짝: 웹 `shared/utils/idempotency-key.ts`(`shouldRenewSendKey`) · `uuid.ts` · `mail-send-result.ts`(`partialSendMessage`) · -/// `pending-send.ts`(`undoToastDeadline`) · `mail-attachment.ts`(`precheckAttachment`) — 같은 규칙을 옮겼다(한쪽을 고치면 둘 다). +/// `pending-send.ts`(`undoToastDeadline`) · `large-file.ts`(`largeFileBlockTextReserve`) — 같은 규칙을 옮겼다(한쪽을 고치면 둘 다). library; import 'dart:math' as math; @@ -99,44 +100,6 @@ Duration? undoSendRemaining({ return remaining > kUndoSendMax ? kUndoSendMax : remaining; } -// ── 첨부 사전 검사 (계약 2절 "첨부 업로드 한도" — 전달하는 원본 · 저장본의 첨부) ── - -/// 메일 한 통의 첨부 개수 한도 (웹 `MAIL_MAX_ATTACHMENTS`) -const int kMailMaxAttachments = 20; - -/// 파일당 크기 대체값 — 서버 기본값과 같다(앱은 첫 판에서 올리지 않아 안내에만 쓴다 — 웹 `MAIL_ATTACHMENT_FALLBACK_MAX_BYTES`) -const int kMailAttachmentMaxBytes = 20 * 1024 * 1024; - -/// 첨부 합계 대체값 — 서버 기본값과 같다(웹 `MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES`) -const int kMailAttachmentTotalMaxBytes = 25 * 1024 * 1024; - -/// 허용 형식(확장자) — 계약 2절 (웹 `MAIL_ATTACHMENT_EXTENSIONS`) -const Set kMailAttachmentExtensions = { - 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'hwp', 'hwpx', 'txt', // - 'csv', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'zip', -}; - -/// 확장자 — 소문자, 없으면 빈 글 -String extensionOf(String name) { - final int dot = name.lastIndexOf('.'); - return dot == -1 ? '' : name.substring(dot + 1).toLowerCase(); -} - -/// 이미 붙은 첨부를 보낼 수 있는가 — 개수(`MAIL_006`) → 형식(`FILE_001`) → 합계(`FILE_002`) 차례(서버와 같다). -/// 전달하는 원본 첨부는 파일당 한도를 보지 않는다(서버도 확장자와 합계만 본다). 걸리면 그 코드, 아니면 null -String? precheckCarriedAttachment({ - required String name, - required int size, - required int attachedCount, - required int attachedBytes, - int totalMaxBytes = kMailAttachmentTotalMaxBytes, -}) { - if (attachedCount + 1 > kMailMaxAttachments) return 'MAIL_006'; - if (!kMailAttachmentExtensions.contains(extensionOf(name))) return 'FILE_001'; - if (attachedBytes + size > totalMaxBytes) return 'FILE_002'; - return null; -} - /// 서버가 덩이를 넣은 뒤 다시 재므로 보내기 직전에 텍스트 한도에서 남기는 몫 — 파일마다 이름 + 24, 머리 600 /// (웹 `largeFileBlockTextReserve` · `LARGE_FILE_BLOCK_TEXT_PER_FILE` · `_BASE`) int largeFileBlockTextReserve(Iterable names) { diff --git a/flutter/lib/features/mail/presentation/mail_texts.dart b/flutter/lib/features/mail/presentation/mail_texts.dart index e0486bd..3506270 100644 --- a/flutter/lib/features/mail/presentation/mail_texts.dart +++ b/flutter/lib/features/mail/presentation/mail_texts.dart @@ -29,9 +29,11 @@ abstract final class MailComposeTexts { /// 보내는 동안의 [보내기] — 화면 낭독기 (앱에서 새로 씀) static const String sending = '보내는 중입니다.'; - /// 앱은 첫 판에서 파일을 올리지 않는다 — [파일 첨부]를 누르면 (앱에서 새로 씀 · 메일 찾기의 "아직 준비 중"과 같은 결) - static const String attachNotReady = - '앱에서 파일 첨부는 아직 준비 중입니다. 지금은 웹메일에서 첨부해 주세요.'; + /// 고르는 창을 열었거나 고른 파일을 가져오는 동안 위의 진행 막대 — 화면 낭독기 (앱에서 새로 씀) + static const String picking = '파일을 가져오는 중'; + + /// 고르는 창을 열지 못했다(열 앱이 없음 · 이 기기에서 아직 고를 수 없음) (앱에서 새로 씀) + static const String pickFailed = '파일을 고르지 못했습니다. 다시 시도해 주세요.'; // ── 칸 ── static const String to = '받는 사람'; @@ -55,6 +57,54 @@ abstract final class MailComposeTexts { // ── 첨부 ── static String attachmentsTitle(int count) => '첨부 ${formatCount(count)}개'; + /// 첨부 머리 오른쪽 — "파일당 최대 20MB · 합계 25MB" (보드 — 한도는 서버 값 `GET /api/mail-quota`) + static String attachmentLimits(String perFile, String total) => + '파일당 최대 $perFile · 합계 $total'; + + /// 올리는 줄 — "올리는 중 40%" (보드) + static String uploadProgress(int percent) => '올리는 중 $percent%'; + + /// 올리는 줄의 진행 막대 — 화면 낭독기 "현장사진_1104.jpg 올리는 중" (보드) + static String uploading(String name) => '$name 올리는 중'; + + /// 올리는 줄의 ✕ — 화면 낭독기 "‘현장사진_1104.jpg’ 올리기 취소" (보드 · 계획) + static String cancelUpload(String name) => '‘$name’ 올리기 취소'; + + /// 올리지 못한 줄 — 그 아래에 사전 문구 (계획) + static const String uploadFailed = '올리지 못했습니다.'; + + /// 올리지 못한 줄의 [다시 시도] — 고른 파일의 복사본으로 다시 올린다 + static const String retryUpload = '다시 시도'; + + /// [다시 시도] — 화면 낭독기 "‘현장사진_1104.jpg’ 다시 올리기" (앱에서 새로 씀) + static String retryUploadLabel(String name) => '‘$name’ 다시 올리기'; + + /// 올리려던 복사본이 없어졌다(기기가 임시 폴더를 비웠다) — 다시 올릴 수 없다 (앱에서 새로 씀) + static const String uploadCopyGone = '올리려던 파일을 찾지 못했습니다. 빼고 다시 골라 주세요.'; + + /// 파일당 한도보다 큰 파일 — "20MB보다 큰 …" (계획 — 한도는 서버 값) + static String fileTooLarge(String limit) => + '$limit보다 큰 파일은 앱에서 아직 보낼 수 없습니다. 웹메일에서 대용량 첨부로 보내 주세요.'; + + /// 합계 한도를 넘게 만드는 파일 — "첨부를 모두 합치면 25MB를 넘어 ‘사진.jpg’은(는) 넣지 못했습니다. …" (계획). + /// 여럿이면 "‘사진.jpg’ 외 2개는"(앱에서 더함). 조사는 `topicParticle` — 확장자로 끝나는 이름은 소리를 몰라 "은(는)" + static String overTotal(String total, String name, {int others = 0}) { + final String target = others > 0 + ? '‘$name’ 외 ${formatCount(others)}개' + : '‘$name’'; + final String particle = topicParticle(others > 0 ? '개' : name); + return '첨부를 모두 합치면 $total를 넘어 $target$particle 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.'; + } + + /// 고른 파일을 읽지 못했다(클라우드 파일을 받지 못함 등) · 가져오다 시한(10분 — `kPickTimeout`)을 넘겼다 (앱에서 새로 씀) + static const String pickReadFailed = '파일을 가져오지 못했습니다. 다시 골라 주세요.'; + + /// 올리는 동안 [보내기] (계획) + static const String attachmentsUploading = '올리는 중인 첨부가 있습니다. 다 올린 뒤 보내 주세요.'; + + /// 고른 파일을 가져오는 동안 [보내기] (앱에서 새로 씀 — 리뷰에서 정함) + static const String attachmentsPicking = '첨부할 파일을 가져오는 중입니다. 다 가져온 뒤 보내 주세요.'; + /// 함께 보낼 수 없는 첨부(형식 · 개수 · 합계) (앱에서 새로 씀) static const String unsendable = '이 첨부는 함께 보낼 수 없습니다. 빼고 보내 주세요.'; @@ -106,6 +156,10 @@ abstract final class MailComposeTexts { static const String leaveMessage = '이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다.'; static const String stay = '계속 쓰기'; static const String leave = '나가기'; + + // 올리는 중 나가기 확인 (계획 — 버튼 · 초점은 위 나가기 확인과 같다) + static const String uploadLeaveTitle = '올리는 중인 첨부가 있습니다'; + static const String uploadLeaveMessage = '이 화면을 나가면 올리던 파일은 빠집니다.'; } /// 읽기 화면 (보드 `App1Reader`). 보드에 없는 상태(붙잡힌 메일 · 본문 안내 · 받지 못함 · 첨부 열기 · 삭제 확인)의 글은 diff --git a/flutter/lib/features/mail/presentation/pages/message_compose_page.dart b/flutter/lib/features/mail/presentation/pages/message_compose_page.dart index 157030d..eaba777 100644 --- a/flutter/lib/features/mail/presentation/pages/message_compose_page.dart +++ b/flutter/lib/features/mail/presentation/pages/message_compose_page.dart @@ -34,7 +34,8 @@ const double _bodyLineHeight = 1.7; /// 메일 쓰기 — 앱 보드 `App1Compose` (앱 디자인 README 「쓰기」). 탭 막대 없는 전체 화면이다. 규칙은 [ComposeController] 머리말. /// -/// - 위 막대: ✕(나가기) · 제목(새 메일 · 답장 · 전체 답장 · 전달 — 저장본을 받기 전에는 "메일 쓰기") · [파일 첨부](첫 판은 준비 중 알림) · [보내기]. +/// - 위 막대: ✕(나가기) · 제목(새 메일 · 답장 · 전체 답장 · 전달 — 저장본을 받기 전에는 "메일 쓰기") · [파일 첨부](고르는 창 — 고른 파일을 가져오는 동안 +/// 위에 진행 막대) · [보내기]. /// - 칸: 받는 사람(오른쪽에 참조 · 숨은참조 펼치기) · 참조 · 숨은참조 · 제목 · (서식이 있는 남의 저장본 안내) · 첨부 · 본문 · 서명 · 원본 메일(접힘 — 누르면 펼친다). /// 열면 비어 있는 첫 칸(받는 사람 → 제목 → 본문)에 초점을 둔다. /// - 보내기 전 검사 · 서버의 칸 안 실패(받는 사람 · 제목 · 본문 길이)는 그 칸 아래 띠로 알리고 그 칸으로 초점을 옮긴다. @@ -43,6 +44,8 @@ const double _bodyLineHeight = 1.7; /// - 나가기(✕ · 뒤로 · 몸짓): 잃을 것이 없으면 그대로 닫는다(예측 뒤로 가기가 산다 — 이 화면이 저장한 적이 있으면 "임시보관함에 저장했습니다."). /// 있으면 쓰다 만 주소를 칩으로 만들고 저장(5초까지)한 뒤 닫고 같은 알림을, 저장하지 못했으면 묻는다 — [계속 쓰기]가 처음 초점이자 주 단추이고 /// 묻는 사이 저장이 끝나면 그대로 닫는다. 보내는 중에는 묻지 않고 떠나게 둔다(결과는 알림으로). +/// 올리는 중인 첨부가 있으면 먼저 따로 묻는다("올리는 중인 첨부가 있습니다") — [나가기]면 올리던 파일을 빼고 위 차례대로, +/// 묻는 사이 모두 다 올렸으면 그대로 떠난다(올리지 못한 파일이 생겼으면 답을 기다린다). /// - 앱이 안 보이게 되면(홈 · 다른 앱 · 화면 끄기) 바꾼 것을 바로 저장한다. class MessageComposePage extends ConsumerStatefulWidget { const MessageComposePage({required this.intent, this.prefill, super.key}); @@ -95,6 +98,12 @@ class _MessageComposePageState extends ConsumerState { /// 창을 열 때의 저장 차례 — 이보다 오르면 그사이 저장이 끝난 것이다 int _askedSaveCount = 0; + /// 올리는 중 나가기 확인 창이 열려 있다 — 그사이 모두 다 올렸으면 창을 닫고 떠난다 + bool _askingUpload = false; + + /// 창을 열 때 올리지 못한 고른 파일의 수 — 묻는 사이 늘었으면(마지막 올리기가 실패) 저절로 떠나지 않고 답을 기다린다 + int _askedUploadFailures = 0; + /// 펼친 원본 메일의 줄 — 인용이 바뀔 때만 다시 만든다 ComposeQuote? _linesQuote; List _lines = const []; @@ -127,6 +136,21 @@ class _MessageComposePageState extends ConsumerState { next.status == ComposeStatus.ready) { _scheduleAutofocus(); } + // 올리는 중 나가기 확인 창이 떠 있다 — 세션이 끝나면 창만 닫고, 모두 다 올렸으면 창을 닫고 떠난다. + // 묻는 사이 올리지 못한 파일이 생겼으면 닫지 않고 답을 기다린다(그 파일 없이 저장하고 떠나며 알리지 않는 일이 없게) + if (_askingUpload && + mounted && + !(ModalRoute.of(context)?.isCurrent ?? true)) { + if (next.ended) { + _askingUpload = false; + Navigator.of(context, rootNavigator: true).pop(false); + } else if (!next.uploading && + failedUploadsOf(next) <= _askedUploadFailures) { + _askingUpload = false; + Navigator.of(context, rootNavigator: true).pop(true); + } + return; + } // 나가기 확인 창이 떠 있다(창이 이미 닫히는 중이면 건드리지 않는다) if (!_askingLeave || !mounted || @@ -178,7 +202,18 @@ class _MessageComposePageState extends ConsumerState { /// ✕ · 막힌 뒤로 가기 — 위 머리말의 차례. 떠나기 전 저장을 기다리는 동안은 [보내기]를 막는다 Future _leave() async { if (_leaving || _closing || !mounted) return; - final ComposeState state = ref.read(composeProvider(_args)); + ComposeState state = ref.read(composeProvider(_args)); + if (state.uploading && !state.sending && !state.ended) { + // 올리는 중인 첨부가 있다 — 먼저 따로 묻는다([나가기]면 올리던 파일을 빼고 아래 차례대로) + setState(() => _leaving = true); + final bool leave = await _askLeaveUploading(); + if (!mounted) return; + setState(() => _leaving = false); + if (!leave) return; + _controller.dropUploadingAttachments(); + state = ref.read(composeProvider(_args)); + if (state.ended) return; + } if (!state.unsaved || state.sending) { _close(announceSaved: _controller.savedForLeave); return; @@ -219,6 +254,22 @@ class _MessageComposePageState extends ConsumerState { } } + /// 올리는 중 나가기 확인 — [계속 쓰기]가 처음 초점이자 주 단추. 묻는 동안 모두 다 올리면 저절로 [나가기]로 닫힌다([_onStateChanged]) + Future _askLeaveUploading() async { + _askingUpload = true; + _askedUploadFailures = failedUploadsOf(ref.read(composeProvider(_args))); + final bool leave = await showConfirmDialog( + context, + title: MailComposeTexts.uploadLeaveTitle, + message: MailComposeTexts.uploadLeaveMessage, + confirmLabel: MailComposeTexts.leave, + cancelLabel: MailComposeTexts.stay, + stayIsPrimary: true, + ); + _askingUpload = false; + return leave; + } + /// 닫는다 — 쌓아 연 화면이면 뒤로, 주소로 바로 열었으면 메일 첫 화면으로 void _close({required bool announceSaved}) { if (_closing || !mounted) return; @@ -318,8 +369,8 @@ class _MessageComposePageState extends ConsumerState { appBar: ComposeAppBar( title: composeTitleOf(state), onClose: () => unawaited(_leave()), - onAttach: editable - ? () => _notice(MailComposeTexts.attachNotReady) + onAttach: editable && !state.picking && !_leaving + ? () => unawaited(_controller.pickAttachments()) : null, onSend: ready && !_leaving ? () => unawaited(_send()) : null, sending: state.sending, @@ -335,7 +386,23 @@ class _MessageComposePageState extends ConsumerState { onRetry: () => unawaited(_controller.retry()), onLeave: () => unawaited(_leave()), ), - ComposeStatus.ready => _form(context, state), + ComposeStatus.ready => Stack( + fit: StackFit.expand, + children: [ + _form(context, state), + // 고르는 창을 열었거나 고른 파일을 가져오는 동안 — 위에 진행 막대(칸을 밀지 않게 겹친다) + if (state.picking) + const Positioned( + top: 0, + left: 0, + right: 0, + child: LinearProgressIndicator( + minHeight: AppSize.progressBarHeight, + semanticsLabel: MailComposeTexts.picking, + ), + ), + ], + ), }, ), ); @@ -394,7 +461,9 @@ class _MessageComposePageState extends ConsumerState { ComposeAttachmentsSection( attachments: state.attachments, largeFiles: state.largeFiles, + limits: state.limits, onRemoveAttachment: controller.removeAttachment, + onRetryAttachment: controller.retryAttachment, onRemoveLargeFile: controller.removeLargeFile, enabled: enabled, ), @@ -470,6 +539,13 @@ class _MessageComposePageState extends ConsumerState { } } +/// 올리지 못한 고른 파일의 수 +int failedUploadsOf(ComposeState state) => state.attachments + .where( + (ComposeAttachment item) => item.upload == ComposeUploadStatus.failed, + ) + .length; + /// 위 막대의 제목 — 쓰기 종류(저장본을 받기 전에는 "메일 쓰기") String composeTitleOf(ComposeState state) { if (!state.titleKnown) return MailComposeTexts.loadingTitle; diff --git a/flutter/lib/features/mail/presentation/providers/attachment_uploads.dart b/flutter/lib/features/mail/presentation/providers/attachment_uploads.dart new file mode 100644 index 0000000..3376515 --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/attachment_uploads.dart @@ -0,0 +1,214 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/domain/compose_attachments.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart'; +import 'package:dio/dio.dart'; + +/// 한 번에 올리는 첨부 수 — 나머지는 고른 차례대로 기다린다(웹은 고른 만큼 한꺼번에 — 휴대폰 망에서는 둘씩) +const int kAttachmentUploadConcurrency = 2; + +/// 첨부 하나 올리기 — 저장소의 `uploadAttachment`(시험은 가짜 서버 · 가짜 함수) +typedef AttachmentUploader = + Future Function({ + required String name, + required String type, + required int length, + required Stream> Function() read, + required CancelToken cancelToken, + required void Function(int sent, int total) onProgress, + }); + +/// 올릴 파일 하나 — 복사본과 보이는 이름 · 크기 +class _UploadJob { + const _UploadJob({ + required this.path, + required this.name, + required this.size, + }); + + final String path; + final String name; + final int size; +} + +/// 쓰기 화면에서 고른 파일 올리기 — 계약 2절 `POST /api/mail-attachments` (웹 `useAttachmentUploads` 와 같은 결). +/// +/// - 고른 차례대로 [kAttachmentUploadConcurrency] 개씩 올린다. 줄의 상태는 쓰기 화면이 소식([onStarted] · [onProgress] · [onDone] · +/// [onFailed])으로 바꾼다 — 이 줄은 차례 · 복사본 · 취소 표만 쥔다. +/// - 복사본(`PickedFileStore`)이 있는 동안은 다시 올릴 수 있다([requeue] — [다시 시도] · 서버가 찾지 못한 첨부 `MAIL_004`). +/// 올리기 전마다 복사본이 아직 있는지 본다(기기가 임시 폴더를 비웠으면 다시 골라야 한다). +/// - 뺀 파일([remove])은 올리던 것을 끊고 복사본을 지운다 — 그 뒤 소식은 오지 않는다. [close] 는 모두 그만두고 복사본을 모두 지운다. +class AttachmentUploadQueue { + AttachmentUploadQueue({ + required AttachmentUploader upload, + required PickedFileStore files, + required this.onStarted, + required this.onProgress, + required this.onDone, + required this.onFailed, + }) : _upload = upload, + _files = files; + + final AttachmentUploader _upload; + final PickedFileStore _files; + + /// 차례가 와 올리기 시작했다 + final void Function(int key) onStarted; + + /// 보낸 비율(0 ~ 100) — 보이는 비율이 바뀔 때만 + final void Function(int key, int percent) onProgress; + + /// 다 올렸다 — 서버의 첨부 + final void Function(int key, MessageAttachment attachment) onDone; + + /// 올리지 못했다 + final void Function(int key, ComposeUploadFailure failure) onFailed; + + /// 복사본이 있는 파일 — 화면의 줄 이름(key)마다 + final Map _jobs = {}; + + /// 차례를 기다리는 파일 — 고른 차례대로 + final List _waiting = []; + + /// 올리는 중인 파일과 그 취소 표 + final Map _running = {}; + + /// 마지막으로 알린 비율 — 같은 비율을 거듭 알리지 않는다 + final Map _percents = {}; + bool _closed = false; + + /// 복사본이 있다 — 다시 올릴 수 있다 + bool hasCopy(int key) => _jobs.containsKey(key); + + /// 올리는 중이거나 기다리는 파일이 있다 + bool get busy => _waiting.isNotEmpty || _running.isNotEmpty; + + /// 고른 파일 하나를 차례에 넣는다 — 그만둔 뒤면 복사본만 지운다 + void add( + int key, { + required String path, + required String name, + required int size, + }) { + if (_closed) { + unawaited(_files.delete(path)); + return; + } + _jobs[key] = _UploadJob(path: path, name: name, size: size); + _waiting.add(key); + _pump(); + } + + /// 복사본으로 다시 올린다([다시 시도] · `MAIL_004` 뒤) — 차례의 끝에 넣는다. + /// 복사본이 없거나 이미 올리는 중 · 기다리는 중이면 거짓 + bool requeue(int key) { + if (_closed || + !_jobs.containsKey(key) || + _waiting.contains(key) || + _running.containsKey(key)) { + return false; + } + _waiting.add(key); + _pump(); + return true; + } + + /// 뺀다 — 올리던 것을 끊고 복사본을 지운다(그 파일의 소식은 더 오지 않는다) + void remove(int key) => removeAll([key]); + + /// 여럿을 뺀다 — 다 뺀 뒤에 다음 차례를 시작한다(뺄 파일이 그사이 시작하지 않게) + void removeAll(Iterable keys) { + for (final int key in keys) { + _waiting.remove(key); + _running.remove(key)?.cancel(); + _percents.remove(key); + final _UploadJob? job = _jobs.remove(key); + if (job != null) unawaited(_files.delete(job.path)); + } + _pump(); + } + + /// 모두 그만둔다 — 올리던 것을 끊고 복사본을 모두 지운다(쓰기 화면이 끝날 때 · 세션이 끝날 때). 그 뒤의 [add] 는 복사본만 지운다 + void close() { + if (_closed) return; + _closed = true; + _waiting.clear(); + for (final CancelToken token in _running.values) { + token.cancel(); + } + _running.clear(); + _percents.clear(); + for (final _UploadJob job in _jobs.values) { + unawaited(_files.delete(job.path)); + } + _jobs.clear(); + } + + void _pump() { + while (!_closed && + _running.length < kAttachmentUploadConcurrency && + _waiting.isNotEmpty) { + final int key = _waiting.removeAt(0); + final _UploadJob? job = _jobs[key]; + if (job == null) continue; + final CancelToken cancel = CancelToken(); + _running[key] = cancel; + _percents[key] = 0; + onStarted(key); + unawaited(_run(key, job, cancel)); + } + } + + /// 그 올리기가 아직 이 파일의 올리기인가 — 뺐거나 그만뒀으면 거짓(그 소식은 버린다) + bool _owns(int key, CancelToken cancel) => + !_closed && identical(_running[key], cancel); + + Future _run(int key, _UploadJob job, CancelToken cancel) async { + MessageAttachment? uploaded; + ComposeUploadFailure? failure; + final String? type = attachmentUploadTypeOf(job.name); + if (type == null) { + // 사전 검사를 지났으면 오지 않는다 — 서버도 같은 까닭으로 거절한다 + failure = ComposeUploadFailure.of('FILE_001'); + } else if (!await _files.exists(job.path)) { + failure = ComposeUploadFailure.copyGone; + } else if (_owns(key, cancel)) { + try { + uploaded = await _upload( + name: job.name, + type: type, + length: job.size, + read: () => _files.openRead(job.path), + cancelToken: cancel, + onProgress: (int sent, int total) { + if (!_owns(key, cancel) || total <= 0) return; + final int percent = (sent * 100 ~/ total).clamp(0, 100); + if (_percents[key] == percent) return; + _percents[key] = percent; + onProgress(key, percent); + }, + ); + } on DioException catch (error) { + failure = error.type == DioExceptionType.cancel + ? null + : ComposeUploadFailure.of(apiErrorCodeOf(error) ?? kFallbackCode); + } on Object { + // 그 밖의 실패(뜻밖의 예외)도 줄을 "올리는 중"에 두지 않는다 — [다시 시도] + failure = ComposeUploadFailure.of(kFallbackCode); + } + } + if (!_owns(key, cancel)) return; + _running.remove(key); + _percents.remove(key); + final MessageAttachment? done = uploaded; + if (done != null) { + onDone(key, done); + } else if (failure != null) { + onFailed(key, failure); + } + _pump(); + } +} diff --git a/flutter/lib/features/mail/presentation/providers/compose_controller.dart b/flutter/lib/features/mail/presentation/providers/compose_controller.dart index ef0d4f1..8bb753c 100644 --- a/flutter/lib/features/mail/presentation/providers/compose_controller.dart +++ b/flutter/lib/features/mail/presentation/providers/compose_controller.dart @@ -1,9 +1,11 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:arasystem_app/core/api/api_error_mapper.dart'; import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/core/api/dio_provider.dart'; import 'package:arasystem_app/core/api/error_notifier.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; import 'package:arasystem_app/core/people/address_input.dart'; import 'package:arasystem_app/core/people/people_texts.dart'; import 'package:arasystem_app/core/people/person_address.dart'; @@ -12,6 +14,7 @@ import 'package:arasystem_app/core/people/recipient_suggestion.dart'; import 'package:arasystem_app/core/storage/token_store.dart'; import 'package:arasystem_app/core/storage/token_store_provider.dart'; import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:arasystem_app/core/utils/file_size.dart'; import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; import 'package:arasystem_app/features/mail/data/models/compose_payload.dart'; import 'package:arasystem_app/features/mail/data/models/mail_address.dart'; @@ -20,6 +23,7 @@ import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; import 'package:arasystem_app/features/mail/data/repositories/compose_repository.dart'; import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/domain/compose_attachments.dart'; import 'package:arasystem_app/features/mail/domain/compose_body.dart'; import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart'; import 'package:arasystem_app/features/mail/domain/compose_fit.dart'; @@ -29,6 +33,7 @@ import 'package:arasystem_app/features/mail/domain/compose_send.dart'; import 'package:arasystem_app/features/mail/domain/compose_signature.dart'; import 'package:arasystem_app/features/mail/mail_routes.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/attachment_uploads.dart'; import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart'; import 'package:arasystem_app/features/mail/presentation/providers/draft_autosave.dart'; import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; @@ -73,6 +78,20 @@ final Provider draftSuccessorsProvider = return DraftSuccessors(); }); +/// 고른 파일의 복사본을 두는 곳 — 메일을 보는 직원이 바뀌면(로그아웃 · 세션 끝 · 다른 직원) 모두 지운다(받은 파일과 같은 결 — `mailDownloadedFilesProvider`). +/// 앱이 살아 있는 동안 둔다. 지난 실행이 남긴 복사본은 main 이 앱을 띄울 때 지운다(`PickedFileStore.purge`) +final Provider composePickedFilesProvider = + Provider((Ref ref) { + final PickedFileStore store = ref.watch(pickedFileStoreProvider); + ref.listen(mailAccountProvider, ( + String? previous, + String? next, + ) { + if (previous != next) unawaited(store.purge()); + }); + return store; + }); + /// 옛 저장본 id → 새 id (웹 `mail.store` 의 `recordDraftSuccessor` · `latestDraftId`) class DraftSuccessors { final Map _next = {}; @@ -120,6 +139,10 @@ const Set kComposeInlineCodes = { 'MAIL_016', }; +/// 전달 원본 · 저장본의 첨부를 검사하기 전에 첨부 한도를 기다리는 가장 긴 시간 — 넘으면 대체값(20 · 25MB)으로 잰다 +/// (웹은 기다리지 않고 받아 둔 값을 쓴다 — 앱은 열 때 한 번 받으므로 잠깐 기다린다. 한도 응답이 늦어도 화면이 그만큼 늦게 열리지 않게) +const Duration kAttachmentLimitsWait = Duration(seconds: 2); + /// 서버에 다시 묻기 전에 기다리는 시간 (웹 `RECIPIENT_SEARCH_DELAY_MS`) const Duration kRecipientSearchDelay = Duration(milliseconds: 250); @@ -189,6 +212,10 @@ class _FormSnapshot { /// 원본 · 저장본을 받지 못하면 [다시 시도] 화면이다. 처음 채운 내용은 "바꿈"이 아니다. /// - 본문은 글만(서식 편집은 다음 판) — 보낼 때 서식 본문 + 텍스트를 함께 만든다(`compose_body.dart`). 원문 인용은 글로 만든 덩이이고 /// 처음에는 한도의 95%, 보내기 · 저장 직전에 100% 로 맞춘다(`compose_fit.dart`). +/// - 첨부: [파일 첨부]는 고르는 창(`PickedFileStore`)으로 고른 파일을 복사해 사전 검사(개수 → 형식 → 파일당 → 합계 — 한도는 `GET /api/mail-quota`, +/// 받지 못하면 20 · 25MB)하고 [AttachmentUploadQueue] 로 둘씩 올린다. 저장 · 보내기에는 다 올린 첨부만 싣고, 올리는 동안은 보내기를 막는다. +/// 서버가 첨부를 찾지 못하면(`MAIL_004`) 고른 파일은 복사본으로 다시 올리고 원본 · 저장본의 첨부는 "찾지 못함"으로 둔다. +/// 복사본은 뺄 때 · 화면이 끝날 때 · 세션이 끝날 때 지운다. /// - 임시 저장은 [DraftAutosave] — 쓰는 동안은 보이지 않고, 저장한 채 나가면 화면이 "임시보관함에 저장했습니다."를 알린다. /// - 보내기: 칸에 쓰다 만 주소 확정 → 사전 검사 → 진행 중인 저장 기다리기 → 길이 다시 재기 → POST(같은 키 — 키를 바꾸는 것은 성공과 `MAIL_005` 뿐). /// 보내는 동안 화면을 떠나도 응답까지 산다 — 결과는 알림으로만, 실패하면 같은 세션일 때만 쓰던 내용을 저장본으로 남긴다. @@ -220,6 +247,20 @@ class ComposeController extends Notifier { /// 웹 · 다른 프로그램이 만든 저장본 — 본문을 고치지 않으면 원래 HTML 그대로 보낸다 ForeignDraftBody? _foreign; int _nextAttachmentKey = 0; + + /// 고른 파일의 복사본 · 올리기 차례 + late final PickedFileStore _files; + late final AttachmentUploadQueue _uploads; + + /// 첨부 한도를 받는 일 — 전달 원본 · 저장본의 첨부를 검사하기 전에 잠깐 기다린다(받지 못하거나 늦으면 대체값) + Future _limitsLoaded = Future.value(); + + /// `MAIL_004` 로 저절로 다시 올린 줄 — 그것을 실은 저장이 성공하기 전에 또 `MAIL_004` 면 다시 올리지 않는다(되풀이를 막는다) + final Set _reuploaded = {}; + + /// 서버가 잃은(`MAIL_004`) 고른 파일 — 다시 올리기에 성공하거나 뺄 때까지, 올리지 못하면 "찾지 못함"으로 보내기 · 저장을 막는다. + /// 이미 이 메일에 실렸던 첨부라 모르는 사이 빠진 채 나가지 않게(웹 `markMissing` — 웹은 다시 올리지 않고 빼기만) + final Set _lostOnServer = {}; int? _undoSendSeconds; bool _savedOnce = false; @@ -262,6 +303,16 @@ class ComposeController extends Notifier { _account = ref.read(mailAccountProvider); _session = _tokens.sessionGeneration; _successors = ref.read(draftSuccessorsProvider); + _files = ref.read(composePickedFilesProvider); + _uploads = AttachmentUploadQueue( + upload: _repository.uploadAttachment, + files: _files, + onStarted: (int key) => _setUpload(key, ComposeUploadStatus.uploading), + onProgress: (int key, int percent) => + _setUpload(key, ComposeUploadStatus.uploading, progress: percent), + onDone: _onUploaded, + onFailed: _onUploadFailed, + ); _autosave = DraftAutosave( canSave: _canSave, hasChanges: _hasDraftChanges, @@ -291,6 +342,7 @@ class ComposeController extends Notifier { _preloadSuggestions(); _preloadGroups(); unawaited(_loadUndoSeconds()); + _limitsLoaded = _loadLimits(); final ComposeState initial; switch (intent) { @@ -374,6 +426,7 @@ class ComposeController extends Notifier { state = ComposeState( kind: state.kind, titleKnown: args.intent is! DraftComposeIntent, + limits: state.limits, ); await _loadSource(_formGeneration); } @@ -429,6 +482,8 @@ class ComposeController extends Notifier { signatureText: prepared?.text, ), ); + // 전달하는 원본 첨부의 합계는 서버 한도로 잰다(받지 못하거나 늦으면 대체값) — 잴 첨부가 있을 때만 기다린다(답장은 기다리지 않는다) + if (forward && detail.attachments.isNotEmpty) await _waitLimits(); if (!_alive(generation)) return; List to = const []; List cc = const []; @@ -491,6 +546,8 @@ class ComposeController extends Notifier { bodyHtml: draft.bodyHtml, body: draft.body, ); + // 저장본의 첨부 합계도 서버 한도로 잰다 — 첨부가 있을 때만 기다린다 + if (draft.attachments.isNotEmpty) await _waitLimits(); if (!_alive(generation)) return; ComposeSignature? signature; ComposeQuote? quote; @@ -560,6 +617,7 @@ class ComposeController extends Notifier { size: attachment.size, attachedCount: count, attachedBytes: bytes, + totalMaxBytes: state.limits.totalMaxBytes, ); if (rejected == null) { count++; @@ -598,6 +656,21 @@ class ComposeController extends Notifier { } } + /// 첨부 한도를 [kAttachmentLimitsWait] 까지 기다린다 — 넘으면 그대로 간다(받으면 뒤에 바뀐다) + Future _waitLimits() => + _limitsLoaded.timeout(kAttachmentLimitsWait, onTimeout: () {}); + + /// 첨부 한도(파일당 · 합계) — 받지 못하면 대체값(20 · 25MB)으로 쓴다 + Future _loadLimits() async { + try { + final AttachmentLimits limits = await _repository.getAttachmentLimits(); + if (!ref.mounted || _ended) return; + if (limits != state.limits) state = state.copyWith(limits: limits); + } on Object { + // 대체값으로 쓴다 — 알리지 않는다(뜻밖의 실패도 열기를 막거나 처리되지 않은 오류로 남지 않게) + } + } + // ── 받는 사람 ─────────────────────────────────────── /// 칸의 글이 바뀌었다 — 구분자(쉼표 · 세미콜론 · 줄바꿈, 따옴표 · 꺾쇠 밖)까지는 칩으로, 남은 글은 그대로. 모자라면 서버에 묻는다 @@ -886,8 +959,12 @@ class ComposeController extends Notifier { _emitUnsaved(); } + /// 첨부를 뺀다 — 고른 파일이면 올리던 것을 끊고(✕ = 올리기 취소) 복사본을 지운다 void removeAttachment(int key) { if (!ref.mounted) return; + _uploads.remove(key); + _reuploaded.remove(key); + _lostOnServer.remove(key); state = state.copyWith( attachments: [ for (final ComposeAttachment item in state.attachments) @@ -898,6 +975,264 @@ class ComposeController extends Notifier { _emitUnsaved(); } + // ── 첨부 고르기 · 올리기 ───────────────────────────── + + /// 한도에 세는 첨부의 수 · 바이트 — 보낼 첨부와 올리는 중 · 다시 올릴 수 있는 고른 파일(`ComposeAttachment.countsTowardLimits`) + ({int count, int bytes}) get _attachedTotals { + int count = 0; + int bytes = 0; + for (final ComposeAttachment item in state.attachments) { + if (!item.countsTowardLimits) continue; + count++; + bytes += item.size; + } + return (count: count, bytes: bytes); + } + + /// [파일 첨부] — 고르는 창을 열고, 고른 파일을 사전 검사한 뒤 올린다(차례: 개수 `MAIL_006` → 형식 `FILE_001` → 파일당 → 합계). + /// + /// 이미 20개면 창을 열지 않고 알린다. 기기는 넘겨받은 한도(남은 개수 · 허용 확장자 · 파일당 · 남은 합계 — 열 때의 한도)로 같은 차례로 미리 걸러 + /// 넣을 파일만 복사하고(받지 않을 형식 · 합계를 넘는 파일은 읽지 않는다), 여기서 다시 판정한다. + /// 넣지 못한 파일은 줄로 남기지 않고 알림 하나로 까닭을 모아 알린다. 고르는 · 가져오는 동안은 [보내기]를 막는다([sendBlockReason]). + /// 고른 결과가 온 때 세션이 끝났거나 보내는 중 · 보냄 · 화면이 떠났으면 복사본만 지운다 + Future pickAttachments() async { + if (!ref.mounted || + _ended || + state.status != ComposeStatus.ready || + state.sending || + state.picking) { + return; + } + final void Function(String text) notice = ref.read(mailNoticeSinkProvider); + final ({int count, int bytes}) attached = _attachedTotals; + if (attached.count >= kMailMaxAttachments) { + notice(ApiException.fromCode('MAIL_006').message); + return; + } + final AttachmentLimits limits = state.limits; + final int generation = _formGeneration; + final PickedFileStore files = _files; + state = state.copyWith(picking: true); + PickResult result; + try { + result = await files.pick( + maxBytes: limits.maxBytes, + maxCount: kMailMaxAttachments - attached.count, + maxTotalBytes: math.max(0, limits.totalMaxBytes - attached.bytes), + extensions: kMailAttachmentTypes.keys.toList(), + ); + } on Object { + // 뜻밖의 실패도 [파일 첨부]를 막힌 채 두지 않는다 + result = const PickResult(PickOutcome.failed); + } + // 고르는 사이 세션이 끝났거나, 보내는 중 · 보냄 · 화면이 떠났다 — 붙이지 않고 복사본만 지운다 + if (!_alive(generation) || + !_sameSession() || + _sent || + state.sending || + !_attached) { + for (final PickedFile file in result.files) { + final String? path = file.path; + if (path != null) unawaited(files.delete(path)); + } + if (ref.mounted && !_ended) state = state.copyWith(picking: false); + return; + } + state = state.copyWith(picking: false); + switch (result.outcome) { + case PickOutcome.picked: + _addPicked(result.files, limits, notice); + case PickOutcome.noApp: + case PickOutcome.failed: + notice(MailComposeTexts.pickFailed); + case PickOutcome.timedOut: + // 고른 파일을 가져오다 시한을 넘겼다(고른 앱이 멈춤) — 고르는 창을 열지 못한 것과 가른다 + notice(MailComposeTexts.pickReadFailed); + case PickOutcome.canceled: + case PickOutcome.busy: + break; + } + } + + /// 고른 파일을 고른 차례대로 검사해 붙이고 올리기 차례에 넣는다 — 앞에서 넣은 파일이 개수 · 합계를 늘린다(웹과 같다) + void _addPicked( + List files, + AttachmentLimits limits, + void Function(String text) notice, + ) { + final ({int count, int bytes}) attached = _attachedTotals; + int count = attached.count; + int bytes = attached.bytes; + final Map> rejected = + >{}; + final List added = []; + final List<({int key, String path, int size, String name})> queued = + <({int key, String path, int size, String name})>[]; + for (final PickedFile file in files) { + final String? path = file.path; + final int? size = file.size; + final AttachmentRejection? rejection = precheckPickedFile( + file, + attachedCount: count, + attachedBytes: bytes, + limits: limits, + ); + if (rejection != null || path == null || size == null) { + (rejected[rejection ?? AttachmentRejection.unreadable] ??= []) + .add(file.name); + if (path != null) unawaited(_files.delete(path)); + continue; + } + count++; + bytes += size; + final int key = _nextAttachmentKey++; + added.add( + ComposeAttachment( + key: key, + id: '', + name: file.name, + size: size, + upload: ComposeUploadStatus.waiting, + ), + ); + queued.add((key: key, path: path, size: size, name: file.name)); + } + if (added.isNotEmpty) { + state = state.copyWith( + attachments: List.unmodifiable([ + ...state.attachments, + ...added, + ]), + ); + for (final ({int key, String path, int size, String name}) item + in queued) { + _uploads.add( + item.key, + path: item.path, + name: item.name, + size: item.size, + ); + } + _emitUnsaved(); + } + if (rejected.isNotEmpty) notice(pickRejectionNotice(rejected, limits)); + } + + /// 올리지 못한 첨부의 [다시 시도] — 복사본으로 다시 올린다(다시 고르지 않는다). 복사본이 없으면 그 까닭으로 바꾼다 + void retryAttachment(int key) { + if (!ref.mounted || _ended || state.sending) return; + final ComposeAttachment? item = _attachmentOf(key); + if (item == null || + item.upload != ComposeUploadStatus.failed || + !(item.failure?.canRetry ?? false)) { + return; + } + _reuploaded.remove(key); + _setUpload(key, ComposeUploadStatus.waiting); + if (!_uploads.requeue(key)) { + // 다시 넣지 못했다(복사본 차례가 없음) — 서버가 잃은 줄이면 "찾지 못함"도 붙어 막힌다(다른 실패와 같은 규칙) + _onUploadFailed(key, ComposeUploadFailure.copyGone); + } + } + + /// 올리는 중 나가기 확인의 [나가기] — 올리던 · 기다리던 첨부를 빼고 그만둔다(다 올린 첨부는 그대로 저장한다) + void dropUploadingAttachments() { + if (!ref.mounted) return; + final Set dropped = { + for (final ComposeAttachment item in state.attachments) + if (item.uploading) item.key, + }; + if (dropped.isEmpty) return; + _uploads.removeAll(dropped); + _reuploaded.removeAll(dropped); + _lostOnServer.removeAll(dropped); + state = state.copyWith( + attachments: [ + for (final ComposeAttachment item in state.attachments) + if (!dropped.contains(item.key)) item, + ], + ); + _emitUnsaved(); + } + + ComposeAttachment? _attachmentOf(int key) { + for (final ComposeAttachment item in state.attachments) { + if (item.key == key) return item; + } + return null; + } + + /// 그 줄만 바꾼다 — 이미 뺀 줄이면 아무것도 하지 않는다 + void _replaceAttachment( + int key, + ComposeAttachment Function(ComposeAttachment item) change, + ) { + final List current = state.attachments; + final int index = current.indexWhere( + (ComposeAttachment item) => item.key == key, + ); + if (index == -1) return; + final ComposeAttachment next = change(current[index]); + if (next == current[index]) return; + state = state.copyWith( + attachments: List.unmodifiable( + List.of(current)..[index] = next, + ), + ); + } + + /// 올리기 소식 — 그 줄의 상태를 바꾼다(다 올리기 전에는 id 를 비운다) + void _setUpload( + int key, + ComposeUploadStatus status, { + int progress = 0, + ComposeUploadFailure? failure, + }) { + if (!ref.mounted || _ended) return; + _replaceAttachment( + key, + (ComposeAttachment item) => + item.withUpload(status, progress: progress, failure: failure), + ); + _emitUnsaved(); + } + + /// 올리지 못했다 — 서버가 잃은(`MAIL_004`) 파일을 다시 올리다 실패했으면 "찾지 못함"도 붙여 빼거나 다시 올리기 전까지 막는다 + /// (복사본이 없어졌으면 [다시 시도]도 없다 — 빼기만) + void _onUploadFailed(int key, ComposeUploadFailure failure) { + if (!ref.mounted || _ended) return; + final bool lost = _lostOnServer.contains(key); + _replaceAttachment(key, (ComposeAttachment item) { + final ComposeAttachment failed = item.withUpload( + ComposeUploadStatus.failed, + failure: failure, + ); + return lost + ? failed.copyWith(problem: ComposeAttachmentProblem.missing) + : failed; + }); + _emitUnsaved(); + } + + /// 다 올렸다 — 서버의 첨부(id · 서버가 정리한 이름 · 크기)로 저장 · 보내기에 싣고 줄에도 그 이름을 보인다(바꿈 — 자동 저장). + /// 고른 앱이 255자를 넘는 이름을 주면 서버가 줄여 저장한다 — 줄이기 전 이름을 실으면 저장 · 발송이 `VAL_001` 이다(웹도 응답의 이름을 싣는다) + void _onUploaded(int key, MessageAttachment attachment) { + if (!ref.mounted || _ended) return; + _lostOnServer.remove(key); + _replaceAttachment( + key, + (ComposeAttachment item) => item.withUpload( + ComposeUploadStatus.done, + id: attachment.id, + progress: 100, + name: attachment.name, + size: attachment.size, + ), + ); + _autosave.touch(); + _emitUnsaved(); + } + void removeLargeFile(String id) { if (!ref.mounted) return; state = state.copyWith( @@ -910,7 +1245,8 @@ class ComposeController extends Notifier { _emitUnsaved(); } - /// [보내기]를 막는 까닭 — 빼야 하는 파일이 먼저(위험 · 지움 · 찾지 못한 첨부), 그다음 웹에서 올리다 멈춘 파일. 막지 않으면 null + /// [보내기]를 막는 까닭 — 빼야 하는 파일이 먼저(위험 · 지움 · 찾지 못한 첨부), 그다음 웹에서 올리다 멈춘 파일, 마지막으로 올리는 중 · 가져오는 중인 첨부 + /// (웹 `sendBlockOf` 와 같은 차례 — 가져오는 중은 앱에서 더함: 드라이브 파일을 받는 사이 누르면 그 파일 없이 나가지 않게). 막지 않으면 null String? get sendBlockReason { final List large = state.largeFiles; if (large.any((DraftLargeFile f) => f.infected)) { @@ -929,6 +1265,8 @@ class ComposeController extends Notifier { )) { return MailComposeTexts.largeUploading; } + if (state.uploading) return MailComposeTexts.attachmentsUploading; + if (state.picking) return MailComposeTexts.attachmentsPicking; return null; } @@ -940,16 +1278,40 @@ class ComposeController extends Notifier { (DraftLargeFile f) => f.status == DraftLargeFileStatus.missing, ); - /// 서버가 첨부를 찾지 못했다(`MAIL_004`) — 어느 것인지 모르니 싣던 첨부를 모두 "찾지 못함"으로(웹과 같다) + /// 서버가 첨부를 찾지 못했다(`MAIL_004`) — 어느 것인지 모르니 싣던 첨부를 모두 손본다(웹 `markMissing` 과 같은 결): + /// 이 화면에서 고른 파일은 복사본으로 한 번 다시 올리고, 다시 올릴 수 없으면(이미 한 번 다시 올림 · 화면이 닫혀 차례가 닫힘) + /// 원본 · 저장본의 첨부처럼 "찾지 못함"으로 둔다 — 빼기 전까지 저장 · 보내기를 막는다(모르는 사이 첨부 없이 나가지 않게). + /// 다시 올리다 실패하면(복사본이 없어짐 등) 그때 "찾지 못함"을 붙인다([_onUploadFailed]) void _markAttachmentsMissing() { + // 다시 올릴 줄 — 그 전의 모습(다시 올리지 못하면 그 모습에 "찾지 못함"을 붙인다) + final Map reupload = {}; + final List next = []; + for (final ComposeAttachment item in state.attachments) { + if (!item.sendable) { + next.add(item); + } else if (item.upload != null && + _uploads.hasCopy(item.key) && + !_reuploaded.contains(item.key)) { + reupload[item.key] = item; + next.add(item.withUpload(ComposeUploadStatus.waiting)); + } else { + next.add(item.copyWith(problem: ComposeAttachmentProblem.missing)); + } + } state = state.copyWith( - attachments: [ - for (final ComposeAttachment item in state.attachments) - item.sendable - ? item.copyWith(problem: ComposeAttachmentProblem.missing) - : item, - ], + attachments: List.unmodifiable(next), ); + for (final MapEntry entry in reupload.entries) { + _reuploaded.add(entry.key); + _lostOnServer.add(entry.key); + if (!_uploads.requeue(entry.key)) { + _replaceAttachment( + entry.key, + (ComposeAttachment _) => + entry.value.copyWith(problem: ComposeAttachmentProblem.missing), + ); + } + } _emitUnsaved(); } @@ -1011,7 +1373,7 @@ class ComposeController extends Notifier { (TextEditingController input) => input.text.trim().isNotEmpty, ); - /// 떠나면 잃는 것이 있다 — 저장하지 않은 변경 · 칸에 쓰다 만 주소 · 찾지 못한 첨부(그동안은 저장하지 않는다) + /// 떠나면 잃는 것이 있다 — 저장하지 않은 변경 · 칸에 쓰다 만 주소 · 찾지 못한 첨부(그동안은 저장하지 않는다) · 올리는 중인 첨부 bool get hasUnsavedChanges { if (!ref.mounted || _ended || @@ -1022,7 +1384,8 @@ class ComposeController extends Notifier { return _hasDraftChanges() || _typingRecipient || _hasMissingAttachments || - _hasMissingLargeFiles; + _hasMissingLargeFiles || + state.uploading; } void _emitUnsaved() { @@ -1146,6 +1509,9 @@ class ComposeController extends Notifier { if (!_alive(generation)) return; _baseline = snapshot; _savedOnce = true; + _reuploaded.removeAll([ + for (final ComposeAttachment item in carried) item.key, + ]); _applySavedAttachments(carried, saved.attachments); // 저장한 차례 — 나가기 확인 창이 "그사이 저장이 끝났다"를 이것으로 안다 state = state.copyWith(saveCount: state.saveCount + 1); @@ -1231,6 +1597,8 @@ class ComposeController extends Notifier { if (!_attached) return; _attached = false; _cancelSearch(); + // 올리던 첨부는 그만두고 복사본을 지운다 — 떠나기 전 저장은 다 올린 첨부만 실었다(보내기는 올리는 동안 막혀 있다) + _uploads.close(); if (!_sent && !_sendingNow) _autosave.stop(); final Set removed = {..._replacedIds}; if ((removed.isNotEmpty || _savedOnce) && !_ended && ref.mounted) { @@ -1536,6 +1904,7 @@ class ComposeController extends Notifier { _ended = true; _autosave.stop(); _cancelSearch(); + _uploads.close(); if (!ref.mounted) return; // 잃을 것이 없다고 본다 — 화면이 묻지 않고 닫히게(다른 직원의 임시보관함에 남기지 않는다). 나가기 확인 창도 알리지 않고 닫는다 state = state.copyWith(ended: true); @@ -1545,6 +1914,7 @@ class ComposeController extends Notifier { void _dispose() { _autosave.dispose(); _cancelSearch(); + _uploads.close(); subject.dispose(); body.dispose(); for (final TextEditingController input in inputs.values) { @@ -1578,6 +1948,31 @@ Future _undoSend( } } +/// 고른 파일 가운데 넣지 못한 것의 알림 — 까닭마다 한 문장, 처음 나온 차례대로 줄을 바꿔 잇는다(알림은 한 번에 하나만 보인다). +/// 개수 · 형식은 사전 문구(`MAIL_006` · `FILE_001`), 크기는 서버 한도로 쓴 글, 합계는 넣지 못한 첫 파일 이름(여럿이면 "외 N개") +String pickRejectionNotice( + Map> rejected, + AttachmentLimits limits, +) => [ + for (final MapEntry> entry + in rejected.entries) + switch (entry.key) { + AttachmentRejection.tooMany => ApiException.fromCode('MAIL_006').message, + AttachmentRejection.notAllowed => ApiException.fromCode( + 'FILE_001', + ).message, + AttachmentRejection.tooLarge => MailComposeTexts.fileTooLarge( + formatMegabytes(limits.maxBytes), + ), + AttachmentRejection.unreadable => MailComposeTexts.pickReadFailed, + AttachmentRejection.overTotal => MailComposeTexts.overTotal( + formatMegabytes(limits.totalMaxBytes), + entry.value.first, + others: entry.value.length - 1, + ), + }, +].join('\n'); + /// 받는 사람 칸의 이름 — "받는 사람" · "참조" · "숨은참조" String fieldLabelOf(RecipientFieldKind kind) => switch (kind) { RecipientFieldKind.to => MailComposeTexts.to, diff --git a/flutter/lib/features/mail/presentation/providers/compose_state.dart b/flutter/lib/features/mail/presentation/providers/compose_state.dart index 5d6bebb..791e839 100644 --- a/flutter/lib/features/mail/presentation/providers/compose_state.dart +++ b/flutter/lib/features/mail/presentation/providers/compose_state.dart @@ -1,6 +1,7 @@ import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/core/people/address_input.dart'; import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; +import 'package:arasystem_app/features/mail/domain/compose_attachments.dart'; import 'package:arasystem_app/features/mail/domain/compose_body.dart'; import 'package:arasystem_app/features/mail/domain/compose_reply.dart'; import 'package:arasystem_app/features/mail/domain/compose_request.dart'; @@ -39,7 +40,7 @@ enum RecipientFieldKind { to, cc, bcc } /// 길이가 한도를 넘은 칸 — 제목 255자 · 본문(인용을 다 줄여도 넘음) enum ComposeLengthError { subject, body } -/// 첨부 줄의 문제 +/// 첨부 줄의 문제 — 원본 · 저장본에 있던 첨부 enum ComposeAttachmentProblem { /// 함께 보낼 수 없는 형식 · 개수 · 합계(사전 검사) — 싣지 않는다 unsendable, @@ -48,7 +49,57 @@ enum ComposeAttachmentProblem { missing, } -/// 쓰기 화면의 일반 첨부 하나 — 전달하는 원본 · 저장본에 이미 있던 첨부(앱은 첫 판에서 새로 올리지 않는다) +/// 이 화면에서 고른 파일의 올리기 상태 (보드 `App1Compose` 「올리는 중 40%」) +enum ComposeUploadStatus { + /// 차례를 기다린다(한 번에 2개씩 올린다) + waiting, + + /// 올리는 중 — 진행 % + uploading, + + /// 다 올렸다 — 저장 · 보내기에 싣는다 + done, + + /// 올리지 못했다 — 사전 문구 + [다시 시도](다시 해도 같은 실패면 빼기만) + failed, +} + +/// 올리지 못한 까닭 +@immutable +class ComposeUploadFailure { + const ComposeUploadFailure({required this.code, required this.canRetry}); + + /// 서버 · 연결 실패 — 형식 · 크기 거절(`FILE_001` · `FILE_002`)과 요청 거절(`VAL_001`)은 다시 올려도 같아 [다시 시도]가 없다 + factory ComposeUploadFailure.of(String code) => + ComposeUploadFailure(code: code, canRetry: !_finalCodes.contains(code)); + + /// 복사본이 없어졌다(기기가 임시 폴더를 비웠다) — 다시 골라야 한다 + static const ComposeUploadFailure copyGone = ComposeUploadFailure( + code: null, + canRetry: false, + ); + + static const Set _finalCodes = { + 'FILE_001', + 'FILE_002', + 'VAL_001', + }; + + /// 사전 문구의 코드 — null 이면 복사본이 없어졌다 + final String? code; + final bool canRetry; + + @override + bool operator ==(Object other) => + other is ComposeUploadFailure && + other.code == code && + other.canRetry == canRetry; + + @override + int get hashCode => Object.hash(code, canRetry); +} + +/// 쓰기 화면의 일반 첨부 하나 — 전달하는 원본 · 저장본에 있던 첨부([upload] 가 null), 또는 이 화면에서 고른 파일([upload] 가 있다) @immutable class ComposeAttachment { const ComposeAttachment({ @@ -57,17 +108,44 @@ class ComposeAttachment { required this.name, required this.size, this.problem, + this.upload, + this.progress = 0, + this.failure, }); /// 화면 안의 이름 — 저장할 때마다 바뀌는 [id] 와 달리 그대로다 final int key; + + /// 서버의 첨부 id — 고른 파일은 다 올리기 전에는 빈 글 final String id; final String name; final int size; final ComposeAttachmentProblem? problem; - /// 저장 · 보내기에 싣는다 - bool get sendable => problem == null; + /// 이 화면에서 고른 파일의 올리기 상태 — 원본 · 저장본의 첨부는 null + final ComposeUploadStatus? upload; + + /// 올리는 중의 비율(0 ~ 100) + final int progress; + + /// 올리지 못한 까닭 — [ComposeUploadStatus.failed] 일 때만 + final ComposeUploadFailure? failure; + + /// 저장 · 보내기에 싣는다 — 문제가 없고, 고른 파일이면 다 올렸다 + bool get sendable => + problem == null && (upload == null || upload == ComposeUploadStatus.done); + + /// 올리는 중이거나 차례를 기다린다 — 보내기를 막고, 나가기는 따로 묻는다 + bool get uploading => + upload == ComposeUploadStatus.waiting || + upload == ComposeUploadStatus.uploading; + + /// 개수 · 합계 한도에 센다 — 보낼 첨부와, 고른 파일 가운데 다시 올릴 수 있는 것(올리지 못했어도 [다시 시도]가 있으면). + /// 함께 보낼 수 없음 · 찾지 못함(원본 · 다시 올릴 수 없는 고른 파일) · 다시 올릴 수 없는 실패는 세지 않는다 + bool get countsTowardLimits { + if (upload == ComposeUploadStatus.failed) return failure?.canRetry ?? false; + return problem == null; + } ComposeAttachment copyWith({String? id, ComposeAttachmentProblem? problem}) => ComposeAttachment( @@ -76,8 +154,30 @@ class ComposeAttachment { name: name, size: size, problem: problem ?? this.problem, + upload: upload, + progress: progress, + failure: failure, ); + /// 고른 파일의 올리기 상태를 바꾼다 — 다 올리기 전에는 id 를 비운다(옛 id 를 싣지 않게). + /// 다 올리면 서버가 정리한 이름 · 크기([name] · [size])로 바꾼다. "찾지 못함"은 지운다 — 다시 올리기 · 실패가 새 상태다(실패에 다시 붙이는 것은 쓰기 화면) + ComposeAttachment withUpload( + ComposeUploadStatus status, { + String id = '', + int progress = 0, + ComposeUploadFailure? failure, + String? name, + int? size, + }) => ComposeAttachment( + key: key, + id: id, + name: name ?? this.name, + size: size ?? this.size, + upload: status, + progress: progress, + failure: failure, + ); + @override bool operator ==(Object other) => other is ComposeAttachment && @@ -85,10 +185,14 @@ class ComposeAttachment { other.id == id && other.name == name && other.size == size && - other.problem == problem; + other.problem == problem && + other.upload == upload && + other.progress == progress && + other.failure == failure; @override - int get hashCode => Object.hash(key, id, name, size, problem); + int get hashCode => + Object.hash(key, id, name, size, problem, upload, progress, failure); } /// 쓰기 화면의 상태 — 받는 사람 칩 · 첨부 · 서명 · 인용 · 칸 안내. 제목 · 본문 · 칸에 쓰는 글은 컨트롤러의 글 칸(TextEditingController)에 있다 @@ -118,6 +222,8 @@ class ComposeState { this.unsaved = false, this.saveCount = 0, this.ended = false, + this.picking = false, + this.limits = kFallbackAttachmentLimits, }); final ComposeStatus status; @@ -170,6 +276,16 @@ class ComposeState { /// 메일을 보는 직원이 바뀌었다(로그아웃 · 세션 끝 · 다른 직원) — 묻지도 저장하지도 알리지도 않는다 final bool ended; + /// 고르는 창을 열었거나 고른 파일을 가져오는 중 — [파일 첨부]를 막고 위에 진행 막대 + final bool picking; + + /// 첨부 한도 — `GET /api/mail-quota`(받기 전 · 받지 못하면 20 · 25MB). 첨부 머리의 한도 글 + final AttachmentLimits limits; + + /// 고른 파일을 올리는 중이거나 차례를 기다린다 + bool get uploading => + attachments.any((ComposeAttachment item) => item.uploading); + List tokensOf(RecipientFieldKind kind) => switch (kind) { RecipientFieldKind.to => to, RecipientFieldKind.cc => cc, @@ -228,6 +344,8 @@ class ComposeState { bool? unsaved, int? saveCount, bool? ended, + bool? picking, + AttachmentLimits? limits, }) => ComposeState( status: status ?? this.status, error: error ?? this.error, @@ -254,5 +372,7 @@ class ComposeState { unsaved: unsaved ?? this.unsaved, saveCount: saveCount ?? this.saveCount, ended: ended ?? this.ended, + picking: picking ?? this.picking, + limits: limits ?? this.limits, ); } diff --git a/flutter/lib/features/mail/presentation/widgets/compose_bars.dart b/flutter/lib/features/mail/presentation/widgets/compose_bars.dart index 5753c77..931df37 100644 --- a/flutter/lib/features/mail/presentation/widgets/compose_bars.dart +++ b/flutter/lib/features/mail/presentation/widgets/compose_bars.dart @@ -26,7 +26,7 @@ class ComposeAppBar extends StatelessWidget implements PreferredSizeWidget { final String title; final VoidCallback onClose; - /// [파일 첨부] — null 이면 꺼진 단추(받는 중 · 받지 못함 · 보내는 중) + /// [파일 첨부](고르는 창) — null 이면 꺼진 단추(받는 중 · 받지 못함 · 보내는 중 · 고르는 중 · 나가는 중) final VoidCallback? onAttach; /// [보내기] — null 이면 꺼진 단추(받는 중 · 받지 못함) diff --git a/flutter/lib/features/mail/presentation/widgets/compose_sections.dart b/flutter/lib/features/mail/presentation/widgets/compose_sections.dart index 21870f0..990b0f6 100644 --- a/flutter/lib/features/mail/presentation/widgets/compose_sections.dart +++ b/flutter/lib/features/mail/presentation/widgets/compose_sections.dart @@ -6,6 +6,7 @@ import 'package:arasystem_app/core/theme/app_theme.dart'; import 'package:arasystem_app/core/theme/app_typography.dart'; import 'package:arasystem_app/core/utils/file_size.dart'; import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; +import 'package:arasystem_app/features/mail/domain/compose_attachments.dart'; import 'package:arasystem_app/features/mail/domain/compose_body.dart'; import 'package:arasystem_app/features/mail/domain/compose_signature.dart'; import 'package:arasystem_app/features/mail/domain/mail_quote.dart'; @@ -28,15 +29,19 @@ const double _quoteLineHeight = 1.7; // ── 첨부 ───────────────────────────────────────────── -/// 첨부 칸 — "첨부 N개" · 줄마다 파일 아이콘 · 이름 · 크기 · ✕ (보드 `App1Compose` 「답장 · 첨부」). +/// 첨부 칸 — "첨부 N개" · 오른쪽에 한도 글 · 줄마다 파일 아이콘 · 이름 · 크기(또는 올리는 진행) · ✕ (보드 `App1Compose` 「답장 · 첨부」). /// -/// 앱은 첫 판에서 새로 올리지 않는다 — 전달하는 원본 · 이어 쓰는 저장본에 이미 있던 첨부만 싣고 뺀다. +/// - 이 화면에서 고른 파일: 올리는 동안 크기 자리에 진행 막대 · "올리는 중 N%" 이고 ✕ 는 올리기 취소다. +/// 올리지 못했으면 "올리지 못했습니다." · 사전 문구 · [다시 시도](다시 해도 같은 실패면 빼기만). +/// - 전달하는 원본 · 이어 쓰는 저장본의 첨부 · 대용량 파일은 싣고 빼기만 한다. /// 실을 수 없는 첨부 · 서버가 찾지 못한 첨부(`MAIL_004`) · 웹에서 올리다 멈췄거나 지웠거나 위험한 대용량 파일은 줄 아래에 빨갛게 까닭을 적는다 class ComposeAttachmentsSection extends StatelessWidget { const ComposeAttachmentsSection({ required this.attachments, required this.largeFiles, + required this.limits, required this.onRemoveAttachment, + required this.onRetryAttachment, required this.onRemoveLargeFile, this.enabled = true, super.key, @@ -44,10 +49,16 @@ class ComposeAttachmentsSection extends StatelessWidget { final List attachments; final List largeFiles; + + /// 머리 오른쪽의 한도 글 — 서버 값(받지 못하면 20 · 25MB) + final AttachmentLimits limits; final ValueChanged onRemoveAttachment; + + /// 올리지 못한 첨부의 [다시 시도] + final ValueChanged onRetryAttachment; final ValueChanged onRemoveLargeFile; - /// 거짓이면 ✕ 를 막는다(보내는 중) + /// 거짓이면 ✕ · [다시 시도]를 막는다(보내는 중) final bool enabled; @override @@ -73,35 +84,42 @@ class ComposeAttachmentsSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - Semantics( - header: true, - child: Text( - MailComposeTexts.attachmentsTitle( - attachments.length + largeFiles.length, + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Semantics( + header: true, + child: Text( + MailComposeTexts.attachmentsTitle( + attachments.length + largeFiles.length, + ), + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: AppTypography.semiBold, + color: theme.colorScheme.onSurfaceVariant, + ), + ), ), - style: theme.textTheme.labelMedium?.copyWith( - fontWeight: AppTypography.semiBold, - color: theme.colorScheme.onSurfaceVariant, + const SizedBox(width: AppSpacing.s8), + Expanded( + child: Text( + MailComposeTexts.attachmentLimits( + formatMegabytes(limits.maxBytes), + formatMegabytes(limits.totalMaxBytes), + ), + key: const ValueKey('compose-attachment-limits'), + textAlign: TextAlign.end, + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: AppTypography.regular, + color: extra.textMuted, + ), + ), ), - ), + ], ), for (final ComposeAttachment item in attachments) ...[ const SizedBox(height: AppSpacing.s8), - _ComposeFileRow( - key: ValueKey('compose-attachment-${item.key}'), - icon: Icons.insert_drive_file_outlined, - name: item.name, - detail: formatFileSize(item.size), - problem: switch (item.problem) { - ComposeAttachmentProblem.unsendable => - MailComposeTexts.unsendable, - ComposeAttachmentProblem.missing => ApiException.fromCode( - 'MAIL_004', - ).message, - null => null, - }, - onRemove: enabled ? () => onRemoveAttachment(item.key) : null, - ), + _attachmentRow(item), ], for (final DraftLargeFile file in largeFiles) ...[ const SizedBox(height: AppSpacing.s8), @@ -109,11 +127,16 @@ class ComposeAttachmentsSection extends StatelessWidget { key: ValueKey('compose-large-file-${file.id}'), icon: Icons.cloud_outlined, name: file.name ?? MailComposeTexts.largeUnknownName, - detail: [ - if (file.size case final int size) formatFileSize(size), - MailReaderTexts.largeFile, - ].join(' · '), + below: _DetailText( + [ + if (file.size case final int size) formatFileSize(size), + MailReaderTexts.largeFile, + ].join(' · '), + ), problem: largeFileProblemOf(file), + removeTooltip: MailComposeTexts.remove( + file.name ?? MailComposeTexts.largeUnknownName, + ), onRemove: enabled ? () => onRemoveLargeFile(file.id) : null, ), ], @@ -121,6 +144,66 @@ class ComposeAttachmentsSection extends StatelessWidget { ), ); } + + Widget _attachmentRow(ComposeAttachment item) { + final IconData icon = isImageAttachment(item.name) + ? Icons.image_outlined + : Icons.insert_drive_file_outlined; + final VoidCallback? remove = enabled + ? () => onRemoveAttachment(item.key) + : null; + final Key key = ValueKey('compose-attachment-${item.key}'); + if (item.uploading) { + return _ComposeFileRow( + key: key, + icon: icon, + name: item.name, + below: _UploadProgress( + name: item.name, + percent: item.upload == ComposeUploadStatus.uploading + ? item.progress + : 0, + ), + removeTooltip: MailComposeTexts.cancelUpload(item.name), + onRemove: remove, + ); + } + if (item.upload == ComposeUploadStatus.failed) { + final ComposeUploadFailure? failure = item.failure; + final String? code = failure?.code; + return _ComposeFileRow( + key: key, + icon: icon, + name: item.name, + below: const _DetailText(MailComposeTexts.uploadFailed, error: true), + problem: code == null + ? MailComposeTexts.uploadCopyGone + : ApiException.fromCode(code).message, + retryLabel: MailComposeTexts.retryUploadLabel(item.name), + onRetry: (failure?.canRetry ?? false) && enabled + ? () => onRetryAttachment(item.key) + : null, + showRetry: failure?.canRetry ?? false, + removeTooltip: MailComposeTexts.remove(item.name), + onRemove: remove, + ); + } + return _ComposeFileRow( + key: key, + icon: icon, + name: item.name, + below: _DetailText(formatFileSize(item.size)), + problem: switch (item.problem) { + ComposeAttachmentProblem.unsendable => MailComposeTexts.unsendable, + ComposeAttachmentProblem.missing => ApiException.fromCode( + 'MAIL_004', + ).message, + null => null, + }, + removeTooltip: MailComposeTexts.remove(item.name), + onRemove: remove, + ); + } } /// 대용량 파일 줄 아래의 까닭 — 보낼 수 있으면 null (차례: 위험 → 지움 → 올리다 멈춤) @@ -133,30 +216,116 @@ String? largeFileProblemOf(DraftLargeFile file) { }; } -/// 쓰기 화면의 파일 줄 — 테두리 상자(모서리 12) · 아이콘 칸(36) · 이름 · 크기 · ✕(48), 문제가 있으면 그 아래 빨간 글 -class _ComposeFileRow extends StatelessWidget { - const _ComposeFileRow({ - required this.icon, - required this.name, - required this.detail, - required this.onRemove, - this.problem, - super.key, - }); +/// 이름 아래 한 줄 — 크기 · "대용량 첨부" · "올리지 못했습니다."(빨강) +class _DetailText extends StatelessWidget { + const _DetailText(this.text, {this.error = false}); + + final String text; + final bool error; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return Text( + text, + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: error ? AppTypography.semiBold : AppTypography.regular, + color: error ? theme.colorScheme.error : extra.textMuted, + ), + ); + } +} + +/// 올리는 줄의 진행 — 막대(4) · "올리는 중 40%" (보드). 막대는 화면 낭독기에 "{이름} 올리는 중" 과 비율로 읽힌다 +class _UploadProgress extends StatelessWidget { + const _UploadProgress({required this.name, required this.percent}); - final IconData icon; final String name; - final String detail; - final String? problem; + final int percent; - /// ✕ — null 이면 꺼진 단추 - final VoidCallback? onRemove; + static const List _tabular = [ + FontFeature.tabularFigures(), + ]; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.s4), + child: Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.xs), + child: LinearProgressIndicator( + value: percent / 100, + minHeight: AppSize.progressBarHeight, + backgroundColor: scheme.surfaceContainerHighest, + color: scheme.primary, + semanticsLabel: MailComposeTexts.uploading(name), + ), + ), + ), + const SizedBox(width: AppSpacing.s8), + ExcludeSemantics( + child: Text( + MailComposeTexts.uploadProgress(percent), + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: AppTypography.regular, + color: scheme.onPrimaryContainer, + fontFeatures: _tabular, + ), + ), + ), + ], + ), + ); + } +} + +/// 쓰기 화면의 파일 줄 — 테두리 상자(모서리 12) · 아이콘 칸(36) · 이름 · 아래 줄 · ✕(48), 문제가 있으면 그 아래 빨간 글과 [다시 시도] +class _ComposeFileRow extends StatelessWidget { + const _ComposeFileRow({ + required this.icon, + required this.name, + required this.below, + required this.removeTooltip, + required this.onRemove, + this.problem, + this.retryLabel, + this.onRetry, + this.showRetry = false, + super.key, + }); + + final IconData icon; + final String name; + + /// 이름 아래 — 크기 · 올리는 진행 · "올리지 못했습니다." + final Widget below; + final String? problem; + + /// ✕ 의 이름(화면 낭독기) — "‘이름’ 빼기" · "‘이름’ 올리기 취소" + final String removeTooltip; + + /// ✕ — null 이면 꺼진 단추 + final VoidCallback? onRemove; + + /// [다시 시도]의 화면 낭독기 이름 + final String? retryLabel; + + /// [다시 시도] — null 이면 꺼진 단추(보내는 중) + final VoidCallback? onRetry; + + /// [다시 시도]를 보인다 + final bool showRetry; @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final ColorScheme scheme = theme.colorScheme; - final AppExtraColors extra = theme.extension()!; final String? reason = problem; final bool failed = reason != null; return Container( @@ -200,7 +369,7 @@ class _ComposeFileRow extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.s4), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ Text( @@ -211,13 +380,7 @@ class _ComposeFileRow extends StatelessWidget { fontWeight: AppTypography.medium, ), ), - Text( - detail, - style: theme.textTheme.labelSmall?.copyWith( - fontWeight: AppTypography.regular, - color: extra.textMuted, - ), - ), + below, if (reason != null) Padding( padding: const EdgeInsets.only(top: AppSpacing.s4), @@ -228,13 +391,33 @@ class _ComposeFileRow extends StatelessWidget { ), ), ), + if (showRetry) + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton( + style: TextButton.styleFrom( + foregroundColor: scheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s8, + ), + textStyle: theme.textTheme.labelLarge?.copyWith( + fontWeight: AppTypography.bold, + ), + ), + onPressed: onRetry, + child: Text( + MailComposeTexts.retryUpload, + semanticsLabel: retryLabel, + ), + ), + ), ], ), ), ), IconButton( icon: const Icon(Icons.close, size: AppSize.iconSmall), - tooltip: MailComposeTexts.remove(name), + tooltip: removeTooltip, color: theme.colorScheme.onSurfaceVariant, onPressed: onRemove, ), diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 549a6ad..ff0ecf3 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -5,6 +5,7 @@ import 'package:arasystem_app/core/api/dio_provider.dart'; import 'package:arasystem_app/core/api/user_agent.dart'; import 'package:arasystem_app/core/constants/app_info_provider.dart'; import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; import 'package:arasystem_app/core/storage/first_run_marker.dart'; import 'package:arasystem_app/core/storage/secure_store.dart'; import 'package:arasystem_app/core/storage/token_store.dart'; @@ -41,9 +42,12 @@ Future main() async { final ({String? appVersion, String userAgent}) device = await loadAppDeviceInfo(); - // 지난 실행이 받아 둔 파일(메일 첨부)을 지운다 — 기다리지 않는다(저장소가 차례를 지켜 새로 받는 파일은 지우지 않는다) + // 지난 실행이 받아 둔 파일(메일 첨부)과 고른 파일의 복사본(쓰기 화면의 첨부)을 지운다 — 기다리지 않는다 + // (저장소가 차례를 지켜 새로 받는 파일 · 새로 고른 파일은 지우지 않는다) final DownloadedFileStore downloadedFiles = CacheDirFileStore(); unawaited(downloadedFiles.purge()); + final PickedFileStore pickedFiles = PlatformPickedFileStore(); + unawaited(pickedFiles.purge()); runApp( ProviderScope( @@ -54,6 +58,7 @@ Future main() async { userAgentProvider.overrideWithValue(device.userAgent), appVersionProvider.overrideWithValue(device.appVersion), downloadedFileStoreProvider.overrideWithValue(downloadedFiles), + pickedFileStoreProvider.overrideWithValue(pickedFiles), ], child: const App(), ), diff --git a/flutter/test/core/files/picked_files_test.dart b/flutter/test/core/files/picked_files_test.dart new file mode 100644 index 0000000..24ef864 --- /dev/null +++ b/flutter/test/core/files/picked_files_test.dart @@ -0,0 +1,338 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/core/files/file_names.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 고른 파일(`PlatformPickedFileStore`) — 채널의 답 읽기 · 복사본 폴더 안에서만 지우기 · 차례. +/// 채널은 시험의 가짜 처리기로, 복사본은 시험마다 만든 임시 폴더(실제 파일 입출력)로 본다 — Kotlin 이 만드는 자리와 같은 꼴 +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const MethodChannel channel = PlatformFileOpener.channel; + late Directory base; + late PlatformPickedFileStore store; + final List calls = []; + + /// 채널의 답 — 시험이 바꿔 끼운다 + late Future Function(MethodCall call) answer; + + setUp(() async { + base = await Directory.systemTemp.createTemp('arasystem-picked-test-'); + store = PlatformPickedFileStore(baseDirectory: () async => base); + calls.clear(); + answer = (MethodCall call) async => { + 'outcome': 'canceled', + }; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) { + calls.add(call); + return answer(call); + }); + }); + + tearDown(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + if (await base.exists()) await base.delete(recursive: true); + }); + + String rootPath() => '${base.path}/$kPickedFilesFolder'; + + /// Kotlin 이 만드는 복사본 — `uploads/<무작위>/<이름>` + Future copyOf(String folder, String name, List bytes) async { + final File file = File('${rootPath()}/$folder/$name'); + await file.create(recursive: true); + await file.writeAsBytes(bytes); + return file.path; + } + + group('채널의 답 읽기', () { + test('고른 차례대로 — 다 복사한 것 · 크기 초과 · 가져오지 않음 · 형식 · 합계로 거름 · 읽지 못함', () { + final PickResult result = PickResult.parse({ + 'outcome': 'picked', + 'files': [ + { + 'name': '견적서.pdf', + 'size': 3, + 'status': 'ok', + 'path': '/c/uploads/a/견적서.pdf', + }, + { + 'name': '영상.mp4', + 'size': 900000000, + 'status': 'tooLarge', + 'path': null, + }, + { + 'name': 'c.png', + 'size': null, + 'status': 'skipped', + }, + { + 'name': 'IMG_0001.HEIC', + 'size': 3, + 'status': 'notAllowed', + }, + { + 'name': '도면.pdf', + 'size': 9, + 'status': 'overTotal', + }, + {'name': null, 'status': 'failed'}, + ], + }); + expect(result.outcome, PickOutcome.picked); + expect(result.files, [ + const PickedFile( + name: '견적서.pdf', + status: PickedFileStatus.ok, + size: 3, + path: '/c/uploads/a/견적서.pdf', + ), + const PickedFile( + name: '영상.mp4', + status: PickedFileStatus.tooLarge, + size: 900000000, + ), + const PickedFile(name: 'c.png', status: PickedFileStatus.skipped), + const PickedFile( + name: 'IMG_0001.HEIC', + status: PickedFileStatus.notAllowed, + size: 3, + ), + const PickedFile( + name: '도면.pdf', + status: PickedFileStatus.overTotal, + size: 9, + ), + const PickedFile( + name: kFallbackFileName, + status: PickedFileStatus.failed, + ), + ]); + }); + + test('다 복사했다는데 경로 · 크기가 없거나 모양이 틀리면 읽지 못한 파일로 본다', () { + final PickResult result = PickResult.parse({ + 'outcome': 'picked', + 'files': [ + {'name': 'a.pdf', 'size': 3, 'status': 'ok'}, + { + 'name': 'b.pdf', + 'status': 'ok', + 'path': '/c/uploads/b/b.pdf', + }, + {'name': 'c.pdf', 'status': 'unknown'}, + '모양이 틀림', + ], + }); + expect( + result.files.map((PickedFile file) => file.status), + everyElement(PickedFileStatus.failed), + ); + expect(result.files.first.name, 'a.pdf'); + }); + + test('고르지 않음 · 열려 있음 · 앱 없음 · 실패 · 모르는 답', () { + PickOutcome outcomeOf(Object? raw) => PickResult.parse(raw).outcome; + expect( + outcomeOf({'outcome': 'canceled'}), + PickOutcome.canceled, + ); + expect( + outcomeOf({'outcome': 'busy'}), + PickOutcome.busy, + ); + expect( + outcomeOf({'outcome': 'noApp'}), + PickOutcome.noApp, + ); + expect( + outcomeOf({'outcome': 'failed'}), + PickOutcome.failed, + ); + expect(outcomeOf({'outcome': '?'}), PickOutcome.failed); + expect(outcomeOf(null), PickOutcome.failed); + expect( + outcomeOf({'outcome': 'picked', 'files': 3}), + PickOutcome.failed, + ); + }); + + test('보이는 이름 — 경로 앞부분 · 제어 · 방향 제어 글자를 지우고 앞뒤 공백을 지운다(다른 글자는 그대로)', () { + expect(displayFileName(' 회의:결과.pdf '), '회의:결과.pdf'); + expect(displayFileName('a/b\\보고서.hwp'), '보고서.hwp'); + expect(displayFileName('invoice\u202Etxt.exe'), 'invoicetxt.exe'); + expect(displayFileName('a\u0000b\nc.png'), 'abc.png'); + expect(displayFileName('\u200B '), kFallbackFileName); + expect(displayFileName(null), kFallbackFileName); + }); + }); + + group('플랫폼 고르기', () { + test('파일 채널의 pick — 남은 개수 · 파일당 · 남은 합계 · 허용 확장자를 넘기고 답을 읽는다', () async { + answer = (MethodCall call) async => { + 'outcome': 'picked', + 'files': [ + { + 'name': 'a.pdf', + 'size': 1, + 'status': 'ok', + 'path': '${rootPath()}/x/a.pdf', + }, + ], + }; + final PickResult result = await store.pick( + maxBytes: 20, + maxCount: 3, + maxTotalBytes: 25, + extensions: ['pdf', 'hwp'], + ); + expect(calls.single.method, PlatformPickedFileStore.pickMethod); + expect(calls.single.arguments, { + 'maxBytes': 20, + 'maxCount': 3, + 'maxTotalBytes': 25, + 'extensions': ['pdf', 'hwp'], + }); + expect(result.files.single.path, '${rootPath()}/x/a.pdf'); + }); + + test( + '기기가 답하지 않으면 시한 뒤 timedOut 으로 끝내고 차례를 넘긴다 — 다음 고르기 · 정리가 묶이지 않는다', + () async { + // 고른 앱이 읽기에서 멈춰 기기가 답하지 않는다 — 첫 고르기의 답은 오지 않는다 + final Completer stuck = Completer(); + answer = (MethodCall call) => calls.length == 1 + ? stuck.future + : Future.value({'outcome': 'canceled'}); + final PlatformPickedFileStore quick = PlatformPickedFileStore( + baseDirectory: () async => base, + pickTimeout: const Duration(milliseconds: 20), + ); + Future pick() => quick.pick( + maxBytes: 1, + maxCount: 1, + maxTotalBytes: 1, + extensions: ['pdf'], + ); + + expect((await pick()).outcome, PickOutcome.timedOut); + expect((await pick()).outcome, PickOutcome.canceled); + expect(calls, hasLength(2)); + await copyOf('f1', 'a.pdf', [1]); + await quick.purge(); + expect(await Directory(rootPath()).exists(), isFalse); + // 늦게 온 답은 버린다(그 복사본은 다음 정리에 지워진다) + stuck.complete({'outcome': 'canceled'}); + expect(kPickTimeout, const Duration(minutes: 10)); + }, + ); + + test('채널이 없거나(iOS) 던지면 실패', () async { + answer = (MethodCall call) async => throw PlatformException(code: 'boom'); + expect( + (await store.pick( + maxBytes: 1, + maxCount: 1, + maxTotalBytes: 1, + extensions: ['pdf'], + )).outcome, + PickOutcome.failed, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + expect( + (await store.pick( + maxBytes: 1, + maxCount: 1, + maxTotalBytes: 1, + extensions: ['pdf'], + )).outcome, + PickOutcome.failed, + ); + }); + + test('모두 지우기는 열린 고르기가 끝난 뒤에 돈다 — 고르는 동안 들어온 정리가 막 고른 복사본까지 지운다', () async { + final Completer picker = Completer(); + answer = (MethodCall call) => picker.future; + final Future picking = store.pick( + maxBytes: 9, + maxCount: 1, + maxTotalBytes: 9, + extensions: ['pdf'], + ); + final Future purging = store.purge(); + await Future.delayed(Duration.zero); + // 고르는 창이 열린 동안 복사본이 생긴다(Kotlin) + final String path = await copyOf('f1', 'a.pdf', [1]); + bool purged = false; + unawaited(purging.then((_) => purged = true)); + await Future.delayed(Duration.zero); + expect(purged, isFalse); + expect(await File(path).exists(), isTrue); + + picker.complete({'outcome': 'canceled'}); + await picking; + await purging; + expect(await Directory(rootPath()).exists(), isFalse); + }); + }); + + group('복사본', () { + test('있는가 · 읽기 · 지우기 — 복사본 폴더 바로 아래 한 칸만, 지우면 그 폴더째', () async { + final String path = await copyOf('f1', '견적서.pdf', [1, 2, 3]); + final String other = await copyOf('f2', '견적서.pdf', [4]); + expect(await store.exists(path), isTrue); + expect( + await store.openRead(path).expand((List chunk) => chunk).toList(), + [1, 2, 3], + ); + + await store.delete(path); + expect(await store.exists(path), isFalse); + expect(await Directory('${rootPath()}/f1').exists(), isFalse); + expect(await store.exists(other), isTrue); + // 이미 없어도 된다 + await store.delete(path); + }); + + test('복사본 폴더 밖 · 폴더 깊숙한 곳은 있다고 보지 않고 지우지 않는다', () async { + final File outside = File('${base.path}/downloads/x/a.pdf'); + await outside.create(recursive: true); + final File deep = File('${rootPath()}/f1/deeper/a.pdf'); + await deep.create(recursive: true); + final File direct = File('${rootPath()}/a.pdf'); + await direct.create(recursive: true); + + for (final File file in [outside, deep, direct]) { + expect(await store.exists(file.path), isFalse, reason: file.path); + await store.delete(file.path); + expect(await file.exists(), isTrue, reason: file.path); + } + // ../ 로 빠져나가는 경로도 + final String escaping = '${rootPath()}/f1/../../downloads/x/a.pdf'; + expect(await store.exists(escaping), isFalse); + await store.delete(escaping); + expect(await outside.exists(), isTrue); + }); + + test('purge — 복사본 폴더를 모두 지운다(없어도 된다) · 임시 폴더를 모르면 조용히 넘어간다', () async { + await copyOf('f1', 'a.pdf', [1]); + await store.purge(); + expect(await Directory(rootPath()).exists(), isFalse); + await store.purge(); + + final PlatformPickedFileStore broken = PlatformPickedFileStore( + baseDirectory: () async => throw MissingPluginException('시험'), + ); + await broken.purge(); + expect(await broken.exists('${rootPath()}/f1/a.pdf'), isFalse); + await broken.delete('${rootPath()}/f1/a.pdf'); + }); + }); +} diff --git a/flutter/test/features/mail/attachment_uploads_test.dart b/flutter/test/features/mail/attachment_uploads_test.dart new file mode 100644 index 0000000..60abd67 --- /dev/null +++ b/flutter/test/features/mail/attachment_uploads_test.dart @@ -0,0 +1,203 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/attachment_uploads.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/file_fakes.dart'; + +/// 받은 올리기 하나 — 시험이 진행을 보내고 끝낸다 +class _Call { + _Call(this.name, this.type, this.length, this.cancel, this.progress); + + final String name; + final String type; + final int length; + final CancelToken cancel; + final void Function(int sent, int total) progress; + final Completer done = Completer(); + + void succeed(String id) => done.complete( + MessageAttachment(id: id, name: name, type: type, size: length), + ); + + void fail(String code) => + done.completeError(apiFailure(RequestOptions(path: '/x'), code)); +} + +/// 첨부 올리기 차례(`AttachmentUploadQueue`) — 둘씩 · 고른 차례 · 진행 · 빼기 · 다시 올리기 · 그만두기 +void main() { + late FakePickedFiles files; + late List<_Call> calls; + late List events; + late AttachmentUploadQueue queue; + + Future upload({ + required String name, + required String type, + required int length, + required Stream> Function() read, + required CancelToken cancelToken, + required void Function(int sent, int total) onProgress, + }) { + final _Call call = _Call(name, type, length, cancelToken, onProgress); + calls.add(call); + unawaited( + cancelToken.whenCancel.then((DioException error) { + if (!call.done.isCompleted) call.done.completeError(error); + }), + ); + return call.done.future; + } + + setUp(() { + files = FakePickedFiles(); + calls = <_Call>[]; + events = []; + queue = AttachmentUploadQueue( + upload: upload, + files: files, + onStarted: (int key) => events.add('start $key'), + onProgress: (int key, int percent) => events.add('$key $percent%'), + onDone: (int key, MessageAttachment attachment) => + events.add('done $key ${attachment.id}'), + onFailed: (int key, ComposeUploadFailure failure) => events.add( + 'failed $key ${failure.code ?? 'copyGone'} ${failure.canRetry}', + ), + ); + }); + + Future settle() => Future.delayed(Duration.zero); + + /// 고른 파일 하나를 차례에 넣는다 — 복사본은 가짜 보관 + PickedFile add(int key, String name, [int size = 4]) { + final PickedFile file = files.copy(name, List.filled(size, key)); + queue.add(key, path: file.path!, name: name, size: size); + return file; + } + + test( + '고른 차례대로 둘까지만 올리고, 하나가 끝나면 다음 — 형식은 확장자의 정식 형식, 다 올린 소식에는 서버의 첨부', + () async { + add(1, 'a.pdf'); + add(2, '보고서.hwp'); + add(3, 'c.png'); + await settle(); + expect(events, ['start 1', 'start 2']); + expect(calls.map((_Call c) => c.name), ['a.pdf', '보고서.hwp']); + expect(calls[1].type, 'application/x-hwp'); + + calls[0].succeed('up-1'); + await settle(); + expect(events, ['start 1', 'start 2', 'done 1 up-1', 'start 3']); + expect(calls, hasLength(3)); + expect(calls[2].type, 'image/png'); + + calls[1].succeed('up-2'); + calls[2].succeed('up-3'); + await settle(); + expect(queue.busy, isFalse); + expect(events.last, 'done 3 up-3'); + }, + ); + + test('진행 — 보이는 비율이 바뀔 때만 알린다(전체를 모르면 알리지 않는다)', () async { + add(1, 'a.pdf', 200); + await settle(); + final _Call call = calls.single; + call.progress(0, 200); + call.progress(1, 200); + call.progress(100, 200); + call.progress(101, 200); + call.progress(5, 0); + call.progress(200, 200); + expect(events, ['start 1', '1 50%', '1 100%']); + }); + + test('뺀 파일 — 올리던 것을 끊고 복사본을 지운다, 그 뒤 소식은 오지 않고 다음 차례가 시작한다', () async { + final PickedFile first = add(1, 'a.pdf'); + add(2, 'b.pdf'); + add(3, 'c.pdf'); + await settle(); + final CancelToken cancel = calls.first.cancel; + + queue.remove(1); + await settle(); + expect(cancel.isCancelled, isTrue); + expect(files.deleted, [first.path!]); + expect(queue.hasCopy(1), isFalse); + expect(events, ['start 1', 'start 2', 'start 3']); + expect(calls.map((_Call c) => c.name), ['a.pdf', 'b.pdf', 'c.pdf']); + }); + + test('여럿을 뺄 때는 다 뺀 뒤에 다음 차례 — 뺄 파일이 그사이 시작하지 않는다', () async { + add(1, 'a.pdf'); + add(2, 'b.pdf'); + add(3, 'c.pdf'); + add(4, 'd.pdf'); + await settle(); + queue.removeAll([1, 2, 3]); + await settle(); + expect(events, ['start 1', 'start 2', 'start 4']); + expect(calls.map((_Call c) => c.name), ['a.pdf', 'b.pdf', 'd.pdf']); + }); + + test('실패 — 형식 · 크기 거절은 다시 할 수 없고, 연결 실패는 복사본으로 다시 올린다', () async { + add(1, 'a.pdf'); + add(2, 'b.pdf'); + await settle(); + calls[0].fail('FILE_002'); + calls[1].fail(kFallbackCode); + await settle(); + expect( + events, + containsAllInOrder([ + 'failed 1 FILE_002 false', + 'failed 2 SYS_001 true', + ]), + ); + expect(queue.busy, isFalse); + + // [다시 시도] — 차례의 끝에 넣고 같은 복사본을 올린다 + expect(queue.requeue(2), isTrue); + expect(queue.requeue(2), isFalse, reason: '이미 올리는 중'); + await settle(); + expect(calls.last.name, 'b.pdf'); + calls.last.succeed('up-9'); + await settle(); + expect(events.last, 'done 2 up-9'); + }); + + test('복사본이 없어졌으면 서버에 묻지 않고 "다시 골라야 함"', () async { + // 기기가 임시 폴더를 비웠다 — 차례가 오기 전에 복사본이 없어졌다 + final PickedFile file = files.copy('a.pdf', [1]); + files.copies.remove(file.path); + queue.add(1, path: file.path!, name: 'a.pdf', size: 1); + await settle(); + expect(calls, isEmpty); + expect(events, ['start 1', 'failed 1 copyGone false']); + }); + + test('그만두기 — 올리던 것을 모두 끊고 복사본을 모두 지운다, 그 뒤 넣는 파일은 복사본만 지운다', () async { + add(1, 'a.pdf'); + add(2, 'b.pdf'); + add(3, 'c.pdf'); + await settle(); + queue.close(); + await settle(); + expect(calls.every((_Call c) => c.cancel.isCancelled), isTrue); + expect(files.copies, isEmpty); + expect(files.deleted, hasLength(3)); + expect(events, ['start 1', 'start 2']); + + add(4, 'd.pdf'); + await settle(); + expect(files.copies, isEmpty); + expect(events, hasLength(2)); + expect(queue.requeue(1), isFalse); + }); +} diff --git a/flutter/test/features/mail/compose_attachments_test.dart b/flutter/test/features/mail/compose_attachments_test.dart new file mode 100644 index 0000000..77098d2 --- /dev/null +++ b/flutter/test/features/mail/compose_attachments_test.dart @@ -0,0 +1,361 @@ +import 'dart:io'; + +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; +import 'package:arasystem_app/core/utils/file_size.dart'; +import 'package:arasystem_app/core/utils/korean_text.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; +import 'package:arasystem_app/features/mail/domain/compose_attachments.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/compose_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const int _mb = 1024 * 1024; + +/// 첨부의 규칙 — 형식 표(백엔드 짝) · 고른 파일 사전 검사 차례 · 한도 읽기 · 넣지 못한 알림 글 · 줄의 셈 +void main() { + group('형식 표 (짝: 백엔드 attachment.policy.ts)', () { + test('확장자 → 정식 형식이 백엔드 ATTACHMENT_TYPES 의 type 과 같다 · 개수 한도도 같다', () { + // flutter/ 에서 돈다(flutter test) — 모노레포의 백엔드 파일을 읽어 맞춰 본다 + final File policy = File( + '../backend/src/modules/mail/attachment.policy.ts', + ); + expect( + policy.existsSync(), + isTrue, + reason: '백엔드 첨부 정책 파일이 없다 — 짝을 맞춰 볼 수 없다', + ); + final String source = policy.readAsStringSync(); + final int start = source.indexOf('const ATTACHMENT_TYPES'); + final int end = source.indexOf(']);', start); + expect(start, isNot(-1)); + final Map backend = { + for (final RegExpMatch match in RegExp( + r"\[\s*'\.([a-z0-9]+)',\s*\{\s*type:\s*'([^']+)'", + ).allMatches(source.substring(start, end))) + match.group(1)!: match.group(2)!, + }; + expect(backend, hasLength(kMailAttachmentTypes.length)); + expect(backend, kMailAttachmentTypes); + expect( + RegExp( + r'MAX_ATTACHMENTS_PER_MESSAGE = (\d+);', + ).firstMatch(source)?.group(1), + '$kMailMaxAttachments', + ); + }); + + test('올릴 때의 형식 — 확장자는 대소문자를 가리지 않는다 · HEIC · 확장자 없음은 허용하지 않는다', () { + expect(attachmentUploadTypeOf('계약.PDF'), 'application/pdf'); + expect(attachmentUploadTypeOf('보고서.hwp'), 'application/x-hwp'); + expect(attachmentUploadTypeOf('보고서.HwPx'), 'application/hwp+zip'); + expect(attachmentUploadTypeOf('사진.jpeg'), 'image/jpeg'); + expect(attachmentUploadTypeOf('묶음.tar.zip'), 'application/zip'); + expect(attachmentUploadTypeOf('IMG_0001.HEIC'), isNull); + expect(attachmentUploadTypeOf('설치.exe'), isNull); + expect(attachmentUploadTypeOf('이름만'), isNull); + expect(isImageAttachment('a.WEBP'), isTrue); + expect(isImageAttachment('a.pdf'), isFalse); + expect(isImageAttachment('a.heic'), isFalse); + }); + }); + + group('고른 파일 사전 검사 (웹 precheckAttachment 와 같은 차례)', () { + const AttachmentLimits limits = AttachmentLimits( + maxBytes: 20 * _mb, + totalMaxBytes: 25 * _mb, + ); + PickedFile ok(String name, int size) => PickedFile( + name: name, + status: PickedFileStatus.ok, + size: size, + path: '/c/uploads/x/$name', + ); + AttachmentRejection? check( + PickedFile file, { + int count = 0, + int bytes = 0, + }) => precheckPickedFile( + file, + attachedCount: count, + attachedBytes: bytes, + limits: limits, + ); + + test('통과', () { + expect(check(ok('견적서.pdf', 5 * _mb), count: 19, bytes: 20 * _mb), isNull); + }); + + test('개수가 먼저 — 허용 밖 형식이어도 20개를 넘으면 개수, 가져오지 않은 파일도 개수', () { + expect(check(ok('a.pdf', 1), count: 20), AttachmentRejection.tooMany); + expect(check(ok('a.heic', 1), count: 20), AttachmentRejection.tooMany); + expect( + check( + const PickedFile(name: 'a.heic', status: PickedFileStatus.skipped), + ), + AttachmentRejection.tooMany, + ); + }); + + test('형식 다음 크기 — 큰 HEIC 는 형식, 큰 PDF 는 크기(복사하다 멈춘 것 · 알려 준 크기 모두)', () { + expect( + check( + const PickedFile( + name: 'IMG_0001.HEIC', + status: PickedFileStatus.tooLarge, + ), + ), + AttachmentRejection.notAllowed, + ); + expect( + check( + const PickedFile(name: 'a.pdf', status: PickedFileStatus.tooLarge), + ), + AttachmentRejection.tooLarge, + ); + expect(check(ok('a.pdf', 20 * _mb + 1)), AttachmentRejection.tooLarge); + expect(check(ok('a.pdf', 20 * _mb)), isNull); + }); + + test('읽지 못한 파일은 형식 · 크기 다음, 합계는 마지막', () { + expect( + check(const PickedFile(name: 'a.exe', status: PickedFileStatus.failed)), + AttachmentRejection.notAllowed, + ); + expect( + check(const PickedFile(name: 'a.pdf', status: PickedFileStatus.failed)), + AttachmentRejection.unreadable, + ); + expect( + check(ok('a.pdf', 6 * _mb), bytes: 20 * _mb), + AttachmentRejection.overTotal, + ); + expect(check(ok('a.pdf', 5 * _mb), bytes: 20 * _mb), isNull); + }); + + test('기기가 같은 차례로 미리 거른 까닭(형식 · 합계)은 그 까닭으로 옮긴다 — 차례가 앞선 까닭이 이긴다', () { + // 기기가 형식으로 걸렀다 — 이름이 허용 형식처럼 보여도 기기의 판정을 따른다(넣지 않는다) + expect( + check( + const PickedFile( + name: 'a.pdf', + status: PickedFileStatus.notAllowed, + size: 1, + ), + ), + AttachmentRejection.notAllowed, + ); + expect( + check( + const PickedFile( + name: '도면.pdf', + status: PickedFileStatus.overTotal, + size: 6 * _mb, + ), + ), + AttachmentRejection.overTotal, + ); + // 개수가 먼저 · 파일당이 합계보다 먼저(웹과 같은 차례) + expect( + check( + const PickedFile( + name: '도면.pdf', + status: PickedFileStatus.overTotal, + size: 6 * _mb, + ), + count: 20, + ), + AttachmentRejection.tooMany, + ); + expect( + check( + const PickedFile( + name: '도면.pdf', + status: PickedFileStatus.overTotal, + size: 21 * _mb, + ), + ), + AttachmentRejection.tooLarge, + ); + }); + + test('이미 붙은 첨부(전달 · 저장본)는 서버 합계 한도로 잰다', () { + expect( + precheckCarriedAttachment( + name: 'a.zip', + size: 6 * _mb, + attachedCount: 0, + attachedBytes: 5 * _mb, + totalMaxBytes: 10 * _mb, + ), + 'FILE_002', + ); + expect( + precheckCarriedAttachment( + name: 'a.zip', + size: 5 * _mb, + attachedCount: 0, + attachedBytes: 5 * _mb, + totalMaxBytes: 10 * _mb, + ), + isNull, + ); + }); + }); + + group('첨부 한도 (GET /api/mail-quota)', () { + test('파일당 · 합계만 읽는다 — 없거나 0 이하면 null(대체값을 쓴다)', () { + expect( + AttachmentLimits.tryParse({ + 'usedBytes': 1, + 'limitBytes': null, + 'attachmentMaxBytes': 30 * _mb, + 'attachmentTotalMaxBytes': 40.0 * _mb, + }), + const AttachmentLimits(maxBytes: 30 * _mb, totalMaxBytes: 40 * _mb), + ); + expect( + AttachmentLimits.tryParse({'attachmentMaxBytes': 1}), + isNull, + ); + expect( + AttachmentLimits.tryParse({ + 'attachmentMaxBytes': 0, + 'attachmentTotalMaxBytes': 1, + }), + isNull, + ); + expect(AttachmentLimits.tryParse('x'), isNull); + expect( + kFallbackAttachmentLimits, + const AttachmentLimits(maxBytes: 20 * _mb, totalMaxBytes: 25 * _mb), + ); + }); + }); + + group('글', () { + test('한도 글 — 웹 formatMegabytes 와 같다(정수면 그대로, 아니면 소수 한 자리)', () { + expect(formatMegabytes(20 * _mb), '20MB'); + expect(formatMegabytes(25 * _mb), '25MB'); + expect(formatMegabytes(20 * _mb + _mb ~/ 2), '20.5MB'); + expect(formatMegabytes(_mb ~/ 2), '0.5MB'); + expect(formatMegabytes(20 * _mb - 1), '20MB'); + expect( + MailComposeTexts.attachmentLimits( + formatMegabytes(20 * _mb), + formatMegabytes(25 * _mb), + ), + '파일당 최대 20MB · 합계 25MB', + ); + }); + + test('은/는 — 받침에 맞추고, 한글로 끝나지 않으면 "은(는)"', () { + expect(topicParticle('사진'), '은'); + expect(topicParticle('보고서'), '는'); + expect(topicParticle('외 2개'), '는'); + expect(topicParticle('견적서.pdf'), '은(는)'); + expect(topicParticle(''), '은(는)'); + }); + + test('넣지 못한 알림 — 까닭마다 한 문장, 처음 나온 차례대로 줄을 바꿔 잇는다', () { + const AttachmentLimits limits = AttachmentLimits( + maxBytes: 20 * _mb, + totalMaxBytes: 25 * _mb, + ); + expect( + pickRejectionNotice(>{ + AttachmentRejection.overTotal: ['현장사진.jpg'], + }, limits), + '첨부를 모두 합치면 25MB를 넘어 ‘현장사진.jpg’은(는) 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.', + ); + expect( + pickRejectionNotice(>{ + AttachmentRejection.overTotal: ['a.jpg', 'b.jpg', 'c.jpg'], + }, limits), + '첨부를 모두 합치면 25MB를 넘어 ‘a.jpg’ 외 2개는 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.', + ); + expect( + pickRejectionNotice(>{ + AttachmentRejection.notAllowed: ['IMG_0001.HEIC', 'b.exe'], + AttachmentRejection.tooLarge: ['영상.mp4'], + AttachmentRejection.tooMany: ['z.pdf'], + AttachmentRejection.unreadable: ['드라이브.pdf'], + }, const AttachmentLimits(maxBytes: 30 * _mb, totalMaxBytes: 40 * _mb)), + [ + ErrorLexicon.getMessage('FILE_001'), + '30MB보다 큰 파일은 앱에서 아직 보낼 수 없습니다. 웹메일에서 대용량 첨부로 보내 주세요.', + ErrorLexicon.getMessage('MAIL_006'), + '파일을 가져오지 못했습니다. 다시 골라 주세요.', + ].join('\n'), + ); + }); + }); + + group('첨부 줄', () { + const ComposeAttachment carried = ComposeAttachment( + key: 1, + id: 'a1', + name: 'a.pdf', + size: 3, + ); + test('보낼 수 있는가 · 올리는 중인가 · 한도에 세는가', () { + final ComposeAttachment picked = carried.withUpload( + ComposeUploadStatus.waiting, + ); + expect(carried.sendable, isTrue); + expect(picked.id, ''); + expect(picked.sendable, isFalse); + expect(picked.uploading, isTrue); + expect(picked.countsTowardLimits, isTrue); + + final ComposeAttachment done = picked.withUpload( + ComposeUploadStatus.done, + id: 'up-1', + progress: 100, + ); + expect(done.sendable, isTrue); + expect(done.uploading, isFalse); + + final ComposeAttachment retryable = picked.withUpload( + ComposeUploadStatus.failed, + failure: ComposeUploadFailure.of('SYS_001'), + ); + expect(retryable.failure?.canRetry, isTrue); + expect(retryable.countsTowardLimits, isTrue); + expect(retryable.sendable, isFalse); + for (final String code in ['FILE_001', 'FILE_002', 'VAL_001']) { + expect(ComposeUploadFailure.of(code).canRetry, isFalse, reason: code); + } + expect( + picked + .withUpload( + ComposeUploadStatus.failed, + failure: ComposeUploadFailure.copyGone, + ) + .countsTowardLimits, + isFalse, + ); + expect( + carried + .copyWith(problem: ComposeAttachmentProblem.missing) + .countsTowardLimits, + isFalse, + ); + // 다시 올릴 수 없어 "찾지 못함"이 된 고른 파일 — 보내지도 세지도 않는다(빼기 전까지 막는다) + final ComposeAttachment missing = done.copyWith( + problem: ComposeAttachmentProblem.missing, + ); + expect(missing.sendable, isFalse); + expect(missing.countsTowardLimits, isFalse); + // 다 올리면 서버가 정리한 이름 · 크기로 + final ComposeAttachment renamed = picked.withUpload( + ComposeUploadStatus.done, + id: 'up-2', + name: '줄인 이름.pdf', + size: 9, + ); + expect((renamed.name, renamed.size), ('줄인 이름.pdf', 9)); + }); + }); +} diff --git a/flutter/test/features/mail/compose_repository_test.dart b/flutter/test/features/mail/compose_repository_test.dart index c269a0e..ac5eb97 100644 --- a/flutter/test/features/mail/compose_repository_test.dart +++ b/flutter/test/features/mail/compose_repository_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:typed_data'; import 'package:arasystem_app/core/api/api_paths.dart'; @@ -5,6 +7,7 @@ import 'package:arasystem_app/core/people/person_address.dart'; import 'package:arasystem_app/core/people/recipient_suggestion.dart'; import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; import 'package:arasystem_app/features/mail/data/models/compose_payload.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; import 'package:arasystem_app/features/mail/data/repositories/compose_repository.dart'; import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; import 'package:dio/dio.dart'; @@ -387,4 +390,202 @@ void main() { ); expect(h.notifier.shownCodes, isEmpty); }); + + group('첨부 올리기 · 첨부 한도 (계약 2절)', () { + Map created( + String id, + String name, + String type, + int size, + ) => { + 'success': true, + 'data': attachmentJson(id, name: name, type: type, size: size), + }; + + test( + 'POST /mail-attachments — multipart file 하나(보이는 이름 · 확장자의 정식 형식 · 길이), 보내기 5분 · 받기 60초 · 알리지 않는다 · 보낸 진행', + () async { + h.server.readRequestBodies = true; + h.server.on( + 'POST', + ComposeApiPaths.mailAttachments, + (_) => FakeResponse( + 201, + created('att-1', '견적서 (최종).hwp', 'application/x-hwp', 5), + ), + ); + final List<(int, int)> progress = <(int, int)>[]; + + final MessageAttachment uploaded = await repository.uploadAttachment( + name: '견적서 (최종).hwp', + type: 'application/x-hwp', + length: 5, + read: () => Stream>.fromIterable(>[ + [0x41, 0x42], + [0x43, 0x44, 0x45], + ]), + cancelToken: CancelToken(), + onProgress: (int sent, int total) => progress.add((sent, total)), + ); + + expect(uploaded.id, 'att-1'); + expect(uploaded.size, 5); + final RequestOptions post = h.server.requests.single; + expect(post.sendTimeout, kAttachmentUploadSendTimeout); + expect(post.receiveTimeout, kAttachmentUploadReceiveTimeout); + expect(bearerTokenOf(post), 'access-1'); + final MapEntry file = + (post.data! as FormData).files.single; + expect(file.key, kAttachmentFileField); + expect(file.value.filename, '견적서 (최종).hwp'); + expect(file.value.contentType.toString(), 'application/x-hwp'); + expect(file.value.length, 5); + // 본문 — 한글 이름은 UTF-8 그대로(백엔드 multer `defParamCharset: utf8`), 내용은 다시 읽은 바이트 + final String body = utf8.decode(h.server.requestBodies.single.body); + expect( + body, + contains( + 'content-disposition: form-data; name="file"; filename="견적서 (최종).hwp"\r\n' + 'content-type: application/x-hwp\r\n\r\nABCDE\r\n', + ), + ); + expect(progress, isNotEmpty); + expect(progress.last.$1, progress.last.$2); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + + test( + '실패는 알리지 않는다(화면이 그 줄에 보인다) · 취소하면 cancel · 모양이 틀린 응답은 SYS_001', + () async { + h.server.on( + 'POST', + ComposeApiPaths.mailAttachments, + (_) => FakeResponse.fail(400, 'FILE_001'), + ); + Future upload(CancelToken cancel) => + repository.uploadAttachment( + name: 'a.pdf', + type: 'application/pdf', + length: 1, + read: () => Stream>.value([1]), + cancelToken: cancel, + onProgress: (int sent, int total) {}, + ); + expect( + apiErrorOf(await errorOf(upload(CancelToken()))).code, + 'FILE_001', + ); + expect(h.notifier.shownCodes, isEmpty); + + final Completer gate = Completer(); + h.server.on('POST', ComposeApiPaths.mailAttachments, (_) async { + await gate.future; + return FakeResponse( + 201, + created('att-2', 'a.pdf', 'application/pdf', 1), + ); + }); + final CancelToken cancel = CancelToken(); + final Future canceled = errorOf(upload(cancel)); + cancel.cancel(); + final Object error = await canceled; + expect((error as DioException).type, DioExceptionType.cancel); + gate.complete(); + + h.server.on( + 'POST', + ComposeApiPaths.mailAttachments, + (_) => const FakeResponse(201, { + 'success': true, + 'data': {'id': 'x'}, + }), + ); + expect( + apiErrorOf(await errorOf(upload(CancelToken()))).code, + 'SYS_001', + ); + }, + ); + + test('토큰이 끝나 401 이면 갱신한 뒤 같은 파일을 처음부터 다시 보낸다(FormData 복제)', () async { + int next = 2; + h.server.readRequestBodies = true; + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(), + 'tokens': tokensJson(next++, now: h.clock.now), + }), + ); + h.server.on( + 'POST', + ComposeApiPaths.mailAttachments, + (RequestOptions options) => bearerTokenOf(options) == 'access-2' + ? FakeResponse(201, created('att-3', 'a.png', 'image/png', 3)) + : FakeResponse.fail(401, 'AUTH_001'), + ); + int reads = 0; + final MessageAttachment uploaded = await repository.uploadAttachment( + name: 'a.png', + type: 'image/png', + length: 3, + read: () { + reads++; + return Stream>.value([7, 8, 9]); + }, + cancelToken: CancelToken(), + onProgress: (int sent, int total) {}, + ); + expect(uploaded.id, 'att-3'); + final List<({String path, List body})> bodies = h + .server + .requestBodies + .where( + (({String path, List body}) b) => + b.path == ComposeApiPaths.mailAttachments, + ) + .toList(); + expect(bodies, hasLength(2)); + expect(bodies[1].body, bodies[0].body); + expect(reads, 2); + }); + + test( + '첨부 한도 — GET /mail-quota 의 파일당 · 합계, 알리지 않는다 · 모양이 틀리면 SYS_001', + () async { + h.server.on( + 'GET', + ComposeApiPaths.mailQuota, + (_) => FakeResponse.ok({ + 'usedBytes': 10, + 'limitBytes': null, + 'attachmentMaxBytes': 30, + 'attachmentTotalMaxBytes': 40, + }), + ); + expect( + await repository.getAttachmentLimits(), + const AttachmentLimits(maxBytes: 30, totalMaxBytes: 40), + ); + h.server.on( + 'GET', + ComposeApiPaths.mailQuota, + (_) => FakeResponse.ok({'usedBytes': 10}), + ); + expect( + apiErrorOf(await errorOf(repository.getAttachmentLimits())).code, + 'SYS_001', + ); + h.server.on( + 'GET', + ComposeApiPaths.mailQuota, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + await errorOf(repository.getAttachmentLimits()); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + }); } diff --git a/flutter/test/features/mail/compose_rules_test.dart b/flutter/test/features/mail/compose_rules_test.dart index fd97654..9ef1055 100644 --- a/flutter/test/features/mail/compose_rules_test.dart +++ b/flutter/test/features/mail/compose_rules_test.dart @@ -4,6 +4,7 @@ import 'package:arasystem_app/core/people/person_address.dart'; import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; import 'package:arasystem_app/features/mail/data/models/compose_payload.dart'; import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/domain/compose_attachments.dart'; import 'package:arasystem_app/features/mail/domain/compose_body.dart'; import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart'; import 'package:arasystem_app/features/mail/domain/compose_fit.dart'; diff --git a/flutter/test/features/mail/message_compose_page_test.dart b/flutter/test/features/mail/message_compose_page_test.dart index 915bb8d..46612e0 100644 --- a/flutter/test/features/mail/message_compose_page_test.dart +++ b/flutter/test/features/mail/message_compose_page_test.dart @@ -1,10 +1,15 @@ import 'dart:async'; +import 'dart:io'; import 'package:arasystem_app/core/api/api_paths.dart'; import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; import 'package:arasystem_app/core/people/people_texts.dart'; import 'package:arasystem_app/core/theme/app_colors.dart'; +import 'package:arasystem_app/core/utils/file_size.dart'; import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/mail/domain/compose_attachments.dart'; import 'package:arasystem_app/features/mail/domain/compose_body.dart'; import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart'; import 'package:arasystem_app/features/mail/domain/compose_fit.dart'; @@ -17,9 +22,11 @@ import 'package:arasystem_app/features/mail/presentation/pages/message_reader_pa import 'package:arasystem_app/features/mail/presentation/providers/compose_controller.dart'; import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/compose_recipient_field.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/compose_sections.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/misc.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -110,9 +117,28 @@ void main() { await tester.pump(); } + /// 마지막 고르기에 넘긴 한도 — 남은 개수 · 파일당 · 남은 합계, 허용 확장자는 늘 형식 표 그대로 + void expectPick({ + required int maxBytes, + required int maxCount, + required int maxTotalBytes, + }) { + final ({ + int maxBytes, + int maxCount, + int maxTotalBytes, + List extensions, + }) + pick = h.picked.picks.last; + expect(pick.maxBytes, maxBytes); + expect(pick.maxCount, maxCount); + expect(pick.maxTotalBytes, maxTotalBytes); + expect(pick.extensions, unorderedEquals(kMailAttachmentTypes.keys)); + } + group('열기', () { testWidgets( - '새 메일 — "새 메일" · 받는 사람 칸에 초점 · 추천 · 그룹을 미리 받는다 · [파일 첨부]는 준비 중', + '새 메일 — "새 메일" · 받는 사람 칸에 초점 · 추천 · 그룹 · 첨부 한도를 미리 받는다 · [파일 첨부]는 고르는 창(닫으면 그대로)', (WidgetTester tester) async { await start(tester); await open(tester, MailRoutes.compose); @@ -130,8 +156,18 @@ void main() { findsNothing, ); + expect(h.server.received('GET', '/mail-quota'), hasLength(1)); + + // 고르는 창 — 남은 자리 · 파일당 · 남은 합계 · 허용 확장자를 넘긴다. 고르지 않고 닫으면 아무것도 없다 await tapTooltip(tester, MailComposeTexts.attach); - expect(snack(MailComposeTexts.attachNotReady), findsOneWidget); + expect(h.picked.picks, hasLength(1)); + expectPick( + maxBytes: 20 * 1024 * 1024, + maxCount: 20, + maxTotalBytes: 25 * 1024 * 1024, + ); + expect(find.byType(SnackBar), findsNothing); + expect(find.text(MailComposeTexts.attachmentsTitle(0)), findsNothing); await tapTooltip(tester, MailComposeTexts.expandCopies); expect(field(RecipientFieldKind.cc), findsOneWidget); @@ -1110,4 +1146,847 @@ void main() { expect(h.server.received('GET', '/drafts/saved-1'), hasLength(1)); }); }); + + group('첨부 고르기 · 올리기 (2차)', () { + const int mb = 1024 * 1024; + + /// 고른 파일 — 크기만 정하고 복사본은 작게 둔다(가짜 서버는 본문을 읽지 않는다) + PickedFile picked(String name, {int size = 4}) { + final PickedFile copy = h.picked.copy(name, [1, 2, 3, 4]); + return PickedFile( + name: name, + status: PickedFileStatus.ok, + size: size, + path: copy.path, + ); + } + + Future attach(WidgetTester tester, List files) async { + h.picked.willPick(files); + await tapTooltip(tester, MailComposeTexts.attach); + } + + /// 다음 올리기 하나의 응답을 붙잡는다 + Completer holdUpload() { + final Completer gate = Completer(); + c.uploadGates.add(gate); + return gate; + } + + List uploadedNames() => [ + for (final ({String name, String type, int length}) upload in c.uploads) + upload.name, + ]; + + Finder inAttachments(Finder finder) => find.descendant( + of: find.byType(ComposeAttachmentsSection), + matching: finder, + ); + + testWidgets( + '[파일 첨부] — 고른 파일을 둘씩 올린다: "올리는 중 N%" → 크기, 첨부 머리에 서버 한도 · 보이는 이름 · 확장자의 정식 형식', + (WidgetTester tester) async { + c.quota = quotaJson(maxBytes: 30 * mb, totalMaxBytes: 40 * mb); + await start(tester); + await open(tester, MailRoutes.compose); + final Completer first = holdUpload(); + final Completer second = holdUpload(); + final Completer third = holdUpload(); + await attach(tester, [ + picked('견적서.pdf'), + picked('현장사진.jpg'), + picked('보고서.hwp'), + ]); + + expect(h.picked.picks, hasLength(1)); + expectPick(maxBytes: 30 * mb, maxCount: 20, maxTotalBytes: 40 * mb); + expect(find.text(MailComposeTexts.attachmentsTitle(3)), findsOneWidget); + expect(find.text('파일당 최대 30MB · 합계 40MB'), findsOneWidget); + expect(find.text(MailComposeTexts.uploadProgress(0)), findsNWidgets(3)); + expect( + find.byTooltip(MailComposeTexts.cancelUpload('견적서.pdf')), + findsOneWidget, + ); + expect( + find.bySemanticsLabel(MailComposeTexts.uploading('현장사진.jpg')), + findsOneWidget, + ); + // 둘까지만 서버에 보낸다 — 셋째는 기다린다 + expect(uploadedNames(), ['견적서.pdf', '현장사진.jpg']); + expect( + c.uploads.map((({String name, String type, int length}) u) => u.type), + ['application/pdf', 'image/jpeg'], + ); + + first.complete(); + await tester.pumpAndSettle(); + expect(uploadedNames().last, '보고서.hwp'); + expect(c.uploads.last.type, 'application/x-hwp'); + expect( + find.byTooltip(MailComposeTexts.remove('견적서.pdf')), + findsOneWidget, + ); + expect(find.text(formatFileSize(4)), findsOneWidget); + + second.complete(); + third.complete(); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.uploadProgress(0)), findsNothing); + expect(find.text(formatFileSize(4)), findsNWidgets(3)); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + + testWidgets( + '올리는 동안 [보내기]는 까닭을 알리고 보내지 않는다 — 임시 저장은 다 올린 첨부만 싣고, 다 올리면 보낸다(닫히면 복사본을 지운다)', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + final Completer gate = holdUpload(); + await attach(tester, [picked('견적서.pdf')]); + + await tapTooltip(tester, MailComposeTexts.send); + expect(snack(MailComposeTexts.attachmentsUploading), findsOneWidget); + expect(c.sends, isEmpty); + + // 쓰는 사이의 저장 — 올리는 중인 첨부는 싣지 않는다 + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + expect(c.saves.single.body['attachments'], isEmpty); + + gate.complete(); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 10)); + await tester.pumpAndSettle(); + expect(c.saves, hasLength(2)); + expect(c.saves.last.body['attachments'], [ + {'id': 'up-1', 'name': '견적서.pdf'}, + ]); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends.single.body['attachments'], [ + {'id': 'saved-2-up-1', 'name': '견적서.pdf'}, + ]); + expect(find.byType(MessageComposePage), findsNothing); + expect(h.picked.copies, isEmpty); + }, + ); + + testWidgets( + '사전 검사 — 형식(HEIC) · 크기 · 읽지 못함 · 개수는 넣지 않고 알림 하나로 까닭을 모은다(그 복사본은 지운다)', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + final PickedFile heic = picked('IMG_0001.HEIC'); + await attach(tester, [ + heic, + const PickedFile( + name: '큰자료.pdf', + status: PickedFileStatus.tooLarge, + size: 900 * mb, + ), + picked('견적서.pdf'), + const PickedFile(name: '드라이브.pdf', status: PickedFileStatus.failed), + const PickedFile(name: 'z.pdf', status: PickedFileStatus.skipped), + ]); + + expect(find.text(MailComposeTexts.attachmentsTitle(1)), findsOneWidget); + expect(find.text('IMG_0001.HEIC'), findsNothing); + expect(find.text('큰자료.pdf'), findsNothing); + expect( + snack( + [ + ErrorLexicon.getMessage('FILE_001'), + '20MB보다 큰 파일은 앱에서 아직 보낼 수 없습니다. 웹메일에서 대용량 첨부로 보내 주세요.', + '파일을 가져오지 못했습니다. 다시 골라 주세요.', + ErrorLexicon.getMessage('MAIL_006'), + ].join('\n'), + ), + findsOneWidget, + ); + expect(h.picked.deleted, [heic.path!]); + expect(uploadedNames(), ['견적서.pdf']); + }, + ); + + testWidgets('합계 한도 — 넘게 만드는 파일만 넣지 않는다(이름 + 은(는)) · 전달하는 원본의 합계도 서버 한도로 잰다', ( + WidgetTester tester, + ) async { + c.quota = quotaJson(totalMaxBytes: mb); + h.mail.put( + kInboxId, + messageJson('f3', mailboxId: kInboxId, subject: '자료', isRead: true), + detail: { + 'attachments': [ + attachmentJson('a1', name: '견적서.pdf', size: 600 * 1024), + attachmentJson('a2', name: '도면.pdf', size: 600 * 1024), + ], + }, + ); + await start(tester); + await open(tester, MailRoutes.composeForward('f3')); + // 둘째 원본은 합계(1MB)를 넘겨 싣지 않는다 + expect(findCopy(MailComposeTexts.unsendable), findsOneWidget); + + await attach(tester, [ + picked('a.pdf', size: 500 * 1024), + picked('b.pdf', size: 300 * 1024), + ]); + expect(find.text('a.pdf'), findsNothing); + expect(find.text('b.pdf'), findsOneWidget); + expect( + snack( + '첨부를 모두 합치면 1MB를 넘어 ‘a.pdf’은(는) 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.', + ), + findsOneWidget, + ); + }); + + testWidgets('첨부 한도가 늦으면 2초까지만 기다리고 대체값으로 연다 — 받으면 한도 글이 바뀐다', ( + WidgetTester tester, + ) async { + c.quota = quotaJson(maxBytes: 5 * mb, totalMaxBytes: mb); + final Completer gate = Completer(); + c.quotaGate = gate; + h.mail.put( + kInboxId, + messageJson('f5', mailboxId: kInboxId, subject: '자료', isRead: true), + detail: { + 'attachments': [ + attachmentJson('a1', name: '견적서.pdf', size: 600 * 1024), + attachmentJson('a2', name: '도면.pdf', size: 600 * 1024), + ], + }, + ); + await start(tester); + unawaited(h.router.push(MailRoutes.composeForward('f5'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text(MailComposeTexts.attachmentsTitle(2)), findsNothing); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + + // 대체값(합계 25MB)으로 쟀다 — 둘 다 싣는다 + expect(find.text(MailComposeTexts.attachmentsTitle(2)), findsOneWidget); + expect(findCopy(MailComposeTexts.unsendable), findsNothing); + expect(find.text('파일당 최대 20MB · 합계 25MB'), findsOneWidget); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text('파일당 최대 5MB · 합계 1MB'), findsOneWidget); + }); + + testWidgets('이미 20개면 고르는 창을 열지 않고 알린다 · 남은 자리만큼만 가져오게 한다', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd1', + attachments: >[ + for (int i = 0; i < 18; i++) + attachmentJson('a$i', name: '자료$i.pdf', size: 10), + ], + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d1')); + + await attach(tester, [picked('a.pdf'), picked('b.pdf')]); + // 남은 자리 2 · 남은 합계 25MB − 18 × 10바이트 + expectPick(maxBytes: 20 * mb, maxCount: 2, maxTotalBytes: 25 * mb - 180); + expect(find.text(MailComposeTexts.attachmentsTitle(20)), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.attach); + expect(h.picked.picks, hasLength(1)); + expect(snack(ErrorLexicon.getMessage('MAIL_006')), findsOneWidget); + }); + + testWidgets( + '✕ 는 올리기 취소 — 줄을 빼고 복사본을 지우고 기다리던 파일이 이어 올라간다 · 한도를 받지 못하면 20 · 25MB', + (WidgetTester tester) async { + c.quota = null; + await start(tester); + await open(tester, MailRoutes.compose); + holdUpload(); + holdUpload(); + final Completer third = holdUpload(); + final PickedFile first = picked('a.pdf'); + await attach(tester, [ + first, + picked('b.pdf'), + picked('c.pdf'), + ]); + expect(h.picked.picks, hasLength(1)); + expectPick(maxBytes: 20 * mb, maxCount: 20, maxTotalBytes: 25 * mb); + expect(find.text('파일당 최대 20MB · 합계 25MB'), findsOneWidget); + expect(uploadedNames(), ['a.pdf', 'b.pdf']); + + await tapTooltip(tester, MailComposeTexts.cancelUpload('a.pdf')); + expect(find.text('a.pdf'), findsNothing); + expect(h.picked.deleted, [first.path!]); + expect(uploadedNames(), ['a.pdf', 'b.pdf', 'c.pdf']); + expect(find.text(MailComposeTexts.attachmentsTitle(2)), findsOneWidget); + + third.complete(); + await tester.pumpAndSettle(); + expect( + find.byTooltip(MailComposeTexts.remove('c.pdf')), + findsOneWidget, + ); + expect( + find.byTooltip(MailComposeTexts.cancelUpload('b.pdf')), + findsOneWidget, + ); + }, + ); + + testWidgets( + '올리지 못하면 "올리지 못했습니다." · 사전 문구 · [다시 시도](복사본으로 다시) — 형식 거절은 빼기만, 알림은 띄우지 않는다', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + c.uploadFailures.addAll([ + FakeResponse.fail(503, 'MAIL_001'), + FakeResponse.fail(400, 'FILE_001'), + ]); + await attach(tester, [picked('a.pdf'), picked('b.pdf')]); + + expect(find.text(MailComposeTexts.uploadFailed), findsNWidgets(2)); + expect( + inAttachments(findCopy(ErrorLexicon.getMessage('MAIL_001'))), + findsOneWidget, + ); + expect( + inAttachments(findCopy(ErrorLexicon.getMessage('FILE_001'))), + findsOneWidget, + ); + expect(find.text(MailComposeTexts.retryUpload), findsOneWidget); + expect( + findCopy(MailComposeTexts.retryUploadLabel('a.pdf')), + findsOneWidget, + ); + expect(h.notifier.shownCodes, isEmpty); + + await tester.tap(find.text(MailComposeTexts.retryUpload)); + await tester.pumpAndSettle(); + expect(uploadedNames(), ['a.pdf', 'b.pdf', 'a.pdf']); + expect(find.text(MailComposeTexts.uploadFailed), findsOneWidget); + expect( + find.byTooltip(MailComposeTexts.remove('a.pdf')), + findsOneWidget, + ); + expect(find.text(MailComposeTexts.retryUpload), findsNothing); + }, + ); + + testWidgets( + '보내기가 첨부를 찾지 못하면(MAIL_004) 고른 파일은 복사본으로 다시 올리고, 전달 원본은 "찾지 못함" — 빼면 보낸다', + (WidgetTester tester) async { + h.mail.put( + kInboxId, + messageJson('f4', mailboxId: kInboxId, subject: '자료', isRead: true), + detail: { + 'attachments': [attachmentJson('a1', name: '원본.pdf')], + }, + ); + await start(tester); + await open(tester, MailRoutes.composeForward('f4')); + await attach(tester, [picked('추가.pdf')]); + await addRecipient(tester, 'doyun@x.kr'); + c.sendFailures.add(FakeResponse.fail(400, 'MAIL_004')); + + await tapTooltip(tester, MailComposeTexts.send); + expect(snack(ErrorLexicon.getMessage('MAIL_004')), findsOneWidget); + expect( + inAttachments(findCopy(ErrorLexicon.getMessage('MAIL_004'))), + findsOneWidget, + ); + expect(uploadedNames(), ['추가.pdf', '추가.pdf']); + expect( + find.byTooltip(MailComposeTexts.remove('추가.pdf')), + findsOneWidget, + ); + + await tapTooltip(tester, MailComposeTexts.remove('원본.pdf')); + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends.last.body['attachments'], [ + {'id': 'up-2', 'name': '추가.pdf'}, + ]); + }, + ); + + testWidgets( + '올리는 중에 나가면 따로 묻는다 — [계속 쓰기]면 그대로, [나가기]면 올리던 파일을 빼고(복사본을 지운다) 닫는다', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + holdUpload(); + final PickedFile file = picked('a.pdf'); + await attach(tester, [file]); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsOneWidget); + expect(findCopy(MailComposeTexts.uploadLeaveMessage), findsOneWidget); + expect( + find.widgetWithText(FilledButton, MailComposeTexts.stay), + findsOneWidget, + ); + await tester.tap(find.text(MailComposeTexts.stay)); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsOneWidget); + expect(find.text(MailComposeTexts.uploadProgress(0)), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.close); + await tester.tap(find.text(MailComposeTexts.leave)); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect(h.picked.deleted, contains(file.path)); + expect(c.saves, isEmpty); + expect(snack(MailComposeTexts.savedOnLeave), findsNothing); + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + }, + ); + + testWidgets('묻는 사이 다 올리면 창을 닫고 다 올린 첨부를 저장한 뒤 떠난다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + final Completer gate = holdUpload(); + await attach(tester, [picked('a.pdf')]); + await tapTooltip(tester, MailComposeTexts.close); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsOneWidget); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsNothing); + expect(find.byType(MessageComposePage), findsNothing); + expect(c.saves.single.body['attachments'], [ + {'id': 'up-1', 'name': 'a.pdf'}, + ]); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + }); + + testWidgets('세션이 끝나면 묻지 않고 올리던 것을 그만두고 복사본을 지운다 — 직원이 바뀌면 복사본 폴더도 비운다', ( + WidgetTester tester, + ) async { + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await start(tester); + await open(tester, MailRoutes.compose); + holdUpload(); + final PickedFile file = picked('a.pdf'); + await attach(tester, [file]); + final int purges = h.picked.purgeCount; + + unawaited(h.container.read(sessionProvider.notifier).signOut()); + await tester.pumpAndSettle(); + + expect(find.byType(MessageComposePage), findsNothing); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsNothing); + expect(h.picked.deleted, contains(file.path)); + expect(h.picked.copies, isEmpty); + expect(h.picked.purgeCount, greaterThan(purges)); + }); + + testWidgets('고르는 · 가져오는 동안 [보내기]는 까닭을 알리고 보내지 않는다 — 다 가져오면 그 파일과 함께 보낸다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + final Completer gate = Completer(); + h.picked.pickGate = gate; + h.picked.willPick([picked('견적서.pdf')]); + await tester.tap(find.byTooltip(MailComposeTexts.attach)); + await tester.pump(); + + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(snack(MailComposeTexts.attachmentsPicking), findsOneWidget); + expect(c.sends, isEmpty); + + gate.complete(); + await tester.pumpAndSettle(); + expect(uploadedNames(), ['견적서.pdf']); + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends.single.body['attachments'], [ + {'id': 'up-1', 'name': '견적서.pdf'}, + ]); + }); + + testWidgets('고른 결과가 화면을 떠난 뒤에 오면 붙이지 않고 복사본만 지운다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + final Completer gate = Completer(); + h.picked.pickGate = gate; + final PickedFile file = picked('견적서.pdf'); + h.picked.willPick([file]); + await tester.tap(find.byTooltip(MailComposeTexts.attach)); + await tester.pump(); + + await tapTooltip(tester, MailComposeTexts.close); + expect(find.byType(MessageComposePage), findsNothing); + + gate.complete(); + await tester.pumpAndSettle(); + expect(h.picked.deleted, [file.path!]); + expect(c.uploads, isEmpty); + }); + + testWidgets('고르는 사이 세션이 끝나면 고른 결과를 붙이지 않고 복사본만 지운다', ( + WidgetTester tester, + ) async { + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await start(tester); + await open(tester, MailRoutes.compose); + final Completer gate = Completer(); + h.picked.pickGate = gate; + final PickedFile file = picked('견적서.pdf'); + h.picked.willPick([file]); + await tester.tap(find.byTooltip(MailComposeTexts.attach)); + await tester.pump(); + + unawaited(h.container.read(sessionProvider.notifier).signOut()); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + + gate.complete(); + await tester.pumpAndSettle(); + expect(h.picked.deleted, contains(file.path)); + expect(c.uploads, isEmpty); + }); + + testWidgets( + '기기가 미리 거른 파일(형식 · 합계)은 개수 · 합계에 들지 않는다 — 18개에 HEIC · 견적서 · 큰도면 · 도면이면 두 PDF 를 넣는다(웹과 같다)', + (WidgetTester tester) async { + c.quota = quotaJson(totalMaxBytes: mb); + c.putDraft( + 'd1', + attachments: >[ + for (int i = 0; i < 18; i++) + attachmentJson('a$i', name: '자료$i.pdf', size: 10), + ], + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d1')); + + // 기기(MainActivity.kt 의 copyPicked)가 같은 차례로 거른 결과 — HEIC 는 읽지 않았고, 큰도면은 합계를 넘겨 넣지 않았다 + await attach(tester, [ + const PickedFile( + name: 'IMG_0001.HEIC', + status: PickedFileStatus.notAllowed, + size: 3, + ), + picked('견적서.pdf', size: 600 * 1024), + const PickedFile( + name: '큰도면.pdf', + status: PickedFileStatus.overTotal, + size: 600 * 1024, + ), + picked('도면.pdf', size: 300 * 1024), + ]); + expectPick(maxBytes: 20 * mb, maxCount: 2, maxTotalBytes: mb - 180); + expect( + find.text(MailComposeTexts.attachmentsTitle(20)), + findsOneWidget, + ); + expect(uploadedNames(), ['견적서.pdf', '도면.pdf']); + expect( + snack( + [ + ErrorLexicon.getMessage('FILE_001'), + '첨부를 모두 합치면 1MB를 넘어 ‘큰도면.pdf’은(는) 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.', + ].join('\n'), + ), + findsOneWidget, + ); + }, + ); + + testWidgets( + '다 올리면 서버가 정리한 이름 · 크기로 싣고 보인다 — 255자 넘는 이름도 저장 · 발송이 거절되지 않게', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + final String longName = '${'가' * 300}.pdf'; + final String serverName = serverFileName(longName); + c.uploadedSize = 6; + await attach(tester, [picked(longName)]); + + // 올릴 때는 고른 이름 그대로 보내고(서버가 줄인다), 다 올리면 서버의 이름으로 + expect(c.uploads.single.name, longName); + expect(serverName.runes.length, 255); + expect(serverName, endsWith('.pdf')); + expect(find.text(serverName), findsOneWidget); + expect(find.text(longName), findsNothing); + expect(find.text(formatFileSize(6)), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends.single.body['attachments'], [ + {'id': 'up-1', 'name': serverName}, + ]); + }, + ); + + testWidgets('다시 올린 뒤 또 MAIL_004 면 "찾지 못함" — 빼기 전까지 보내기 · 저장을 막는다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await attach(tester, [picked('추가.pdf')]); + await addRecipient(tester, 'doyun@x.kr'); + c.sendFailures.addAll([ + FakeResponse.fail(400, 'MAIL_004'), + FakeResponse.fail(400, 'MAIL_004'), + ]); + + await tapTooltip(tester, MailComposeTexts.send); + expect(uploadedNames(), ['추가.pdf', '추가.pdf']); + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(2)); + // 이미 한 번 다시 올렸다 — 또 올리지 않고 빼기 전까지 막는다 + expect(uploadedNames(), hasLength(2)); + expect( + inAttachments(findCopy(ErrorLexicon.getMessage('MAIL_004'))), + findsOneWidget, + ); + expect(find.text(MailComposeTexts.retryUpload), findsNothing); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(2)); + expect(snack(ErrorLexicon.getMessage('MAIL_004')), findsOneWidget); + await tester.pump(const Duration(seconds: 30)); + await tester.pumpAndSettle(); + expect(c.saves, isEmpty); + + await tapTooltip(tester, MailComposeTexts.remove('추가.pdf')); + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(3)); + expect(c.sends.last.body['attachments'], isEmpty); + }); + + testWidgets('MAIL_004 뒤 복사본이 없어져 다시 올리지 못하면 빼기 전까지 보내지 않는다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await attach(tester, [picked('추가.pdf')]); + await addRecipient(tester, 'doyun@x.kr'); + // 기기가 임시 폴더를 비웠다 + h.picked.copies.clear(); + c.sendFailures.add(FakeResponse.fail(400, 'MAIL_004')); + + await tapTooltip(tester, MailComposeTexts.send); + expect(uploadedNames(), hasLength(1)); + expect( + inAttachments(findCopy(MailComposeTexts.uploadCopyGone)), + findsOneWidget, + ); + expect(find.text(MailComposeTexts.retryUpload), findsNothing); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(1)); + expect(snack(ErrorLexicon.getMessage('MAIL_004')), findsOneWidget); + }); + + testWidgets('MAIL_004 뒤 다시 올리기가 실패하면 [다시 시도]로 다 올릴 때까지 보내지 않는다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await attach(tester, [picked('추가.pdf')]); + await addRecipient(tester, 'doyun@x.kr'); + c.sendFailures.add(FakeResponse.fail(400, 'MAIL_004')); + c.uploadFailures.add(FakeResponse.fail(503, 'MAIL_001')); + + await tapTooltip(tester, MailComposeTexts.send); + expect(uploadedNames(), hasLength(2)); + expect(find.text(MailComposeTexts.uploadFailed), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(1)); + expect(snack(ErrorLexicon.getMessage('MAIL_004')), findsOneWidget); + + await tester.tap(find.text(MailComposeTexts.retryUpload)); + await tester.pumpAndSettle(); + expect(uploadedNames(), hasLength(3)); + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(2)); + expect(c.sends.last.body['attachments'], [ + {'id': 'up-2', 'name': '추가.pdf'}, + ]); + }); + + testWidgets('떠난 뒤 기다린 저장이 고른 파일을 찾지 못하면(MAIL_004) 그 파일 없이 보내지 않고 알린다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await attach(tester, [picked('추가.pdf')]); + await addRecipient(tester, 'doyun@x.kr'); + final Completer gate = Completer(); + c.saveGate = gate; + await tester.pump(const Duration(seconds: 3)); + expect(c.saves, hasLength(1)); + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + // 보내는 중 — 묻지 않고 떠난다(올리기 차례 · 복사본이 닫혀 다시 올릴 수 없다) + await tapTooltip(tester, MailComposeTexts.close); + expect(find.byType(MessageComposePage), findsNothing); + + c.draftFailures.add(FakeResponse.fail(400, 'MAIL_004')); + gate.complete(); + await tester.pumpAndSettle(); + + expect(c.sends, isEmpty); + expect(h.notifier.shownCodes, ['MAIL_004']); + expect(c.saves, hasLength(1)); + expect(uploadedNames(), hasLength(1)); + }); + + testWidgets('묻는 사이 마지막 올리기가 실패하면 창을 닫지 않고 답을 기다린다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + final Completer gate = holdUpload(); + await attach(tester, [picked('a.pdf')]); + await tapTooltip(tester, MailComposeTexts.close); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsOneWidget); + + c.uploadFailures.add(FakeResponse.fail(503, 'MAIL_001')); + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsOneWidget); + expect(find.byType(MessageComposePage), findsOneWidget); + + await tester.tap(find.text(MailComposeTexts.stay)); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.uploadFailed), findsOneWidget); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsNothing); + }); + + testWidgets('답장 · 첨부 없는 저장본은 첨부 한도를 기다리지 않고 연다', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('r1', mailboxId: kInboxId, subject: '안부', isRead: true), + ); + c.putDraft('d2', subject: '쓰던 메일'); + await start(tester); + + c.quotaGate = Completer(); + unawaited(h.router.push(MailRoutes.composeReply('r1'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(textOf(tester, subjectField), 'Re: 안부'); + await tapTooltip(tester, MailComposeTexts.close); + expect(find.byType(MessageComposePage), findsNothing); + + c.quotaGate = Completer(); + unawaited(h.router.push(MailRoutes.composeDraft('d2'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(textOf(tester, subjectField), '쓰던 메일'); + }); + + testWidgets( + '기기가 답하지 않는 고르기는 시한(10분) 뒤 풀린다 — 알리고 [보내기] · [파일 첨부]가 다시 되고 다음 고르기가 묶이지 않는다', + (WidgetTester tester) async { + // 기기 채널 쪽 차례 · 시한까지 보려고 실제 보관소를 쓴다 — 첫 고르기는 답이 오지 않는다(고른 앱이 멈춤) + final List channelCalls = []; + final Completer stuck = Completer(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(PlatformFileOpener.channel, ( + MethodCall call, + ) { + channelCalls.add(call); + return channelCalls.length == 1 + ? stuck.future + : Future.value({ + 'outcome': 'canceled', + }); + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding + .instance + .defaultBinaryMessenger + .setMockMethodCallHandler(PlatformFileOpener.channel, null), + ); + h.pickedStore = PlatformPickedFileStore( + baseDirectory: () async => Directory.systemTemp, + ); + final Finder pickingBar = find.byWidgetPredicate( + (Widget widget) => + widget is LinearProgressIndicator && + widget.semanticsLabel == MailComposeTexts.picking, + ); + final Finder attachButton = find.widgetWithIcon( + IconButton, + Icons.attach_file, + ); + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + + await tester.tap(find.byTooltip(MailComposeTexts.attach)); + await tester.pump(); + expect(pickingBar, findsOneWidget); + expect(tester.widget(attachButton).onPressed, isNull); + + await tester.pump(kPickTimeout); + await tester.pumpAndSettle(); + expect(pickingBar, findsNothing); + expect(snack(MailComposeTexts.pickReadFailed), findsOneWidget); + expect(tester.widget(attachButton).onPressed, isNotNull); + + // 다음 고르기는 시한으로 끝난 고르기 뒤에 묶이지 않는다 + await tapTooltip(tester, MailComposeTexts.attach); + expect(channelCalls, hasLength(2)); + expect(pickingBar, findsNothing); + + // [보내기]도 다시 된다 + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(1)); + }, + ); + + testWidgets('고르는 동안 위에 진행 막대 · [파일 첨부]는 막힌다 — 고르지 못하면 알린다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + final Completer gate = Completer(); + h.picked.pickGate = gate; + final Finder pickingBar = find.byWidgetPredicate( + (Widget widget) => + widget is LinearProgressIndicator && + widget.semanticsLabel == MailComposeTexts.picking, + ); + final Finder attachButton = find.widgetWithIcon( + IconButton, + Icons.attach_file, + ); + + await tester.tap(find.byTooltip(MailComposeTexts.attach)); + await tester.pump(); + expect(pickingBar, findsOneWidget); + expect(tester.widget(attachButton).onPressed, isNull); + + gate.complete(); + await tester.pumpAndSettle(); + expect(pickingBar, findsNothing); + expect(tester.widget(attachButton).onPressed, isNotNull); + expect(find.byType(SnackBar), findsNothing); + + h.picked.results.add(const PickResult(PickOutcome.failed)); + await tapTooltip(tester, MailComposeTexts.attach); + expect(snack(MailComposeTexts.pickFailed), findsOneWidget); + }); + }); } diff --git a/flutter/test/support/compose_fakes.dart b/flutter/test/support/compose_fakes.dart index 2e048ff..caf083c 100644 --- a/flutter/test/support/compose_fakes.dart +++ b/flutter/test/support/compose_fakes.dart @@ -11,7 +11,7 @@ import 'mail_fakes.dart'; typedef SentRequest = ({Map body, String? key}); /// 쓰기 화면의 API 를 흉내 내는 가짜 뒤쪽 — 임시 저장(POST · PUT · GET) · 발송 · 서명 · 서명 그림 · 보내기 설정 · -/// 받는 사람 추천 · 주소록 그룹 · 구성원 · 대용량 파일 상태. +/// 받는 사람 추천 · 주소록 그룹 · 구성원 · 대용량 파일 상태 · 첨부 올리기 · 첨부 한도. /// /// 저장본은 메모리에 둔다 — PUT 은 새 id 로 바꾸고 옛것을 지운다(계약 2절). 실패([draftFailure] · [sendFailure])와 /// 문([saveGate] · [sendGate])으로 응답을 늦추거나 실패시킨다. @@ -74,8 +74,28 @@ class FakeComposeBackend { /// 있으면 대용량 파일 상태 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) Completer? largeFileGate; + /// 받은 첨부 올리기 차례대로 — multipart 의 이름 · 형식 · 길이 + final List<({String name, String type, int length})> uploads = + <({String name, String type, int length})>[]; + + /// 다음 올리기들이 이 응답으로 실패한다(차례대로 하나씩 쓴다) + final List uploadFailures = []; + + /// 올리기 응답이 차례대로 이것들이 열릴 때까지 기다린다(온 차례대로 하나씩 쓴다 — 없으면 바로 답한다) + final List> uploadGates = >[]; + + /// 첨부 한도 응답(`MailQuota`) — null 이면 받지 못한다(503 `MAIL_001`) + Map? quota = quotaJson(); + + /// 있으면 올리기 응답의 크기를 이것으로 준다(서버가 센 크기 — 없으면 보낸 길이) + int? uploadedSize; + + /// 있으면 첨부 한도 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) + Completer? quotaGate; + int _nextDraft = 0; int _nextSent = 0; + int _nextUpload = 0; static final RegExp _draftPath = RegExp(r'^/drafts/([^/]+)$'); static final RegExp _imagePath = RegExp(r'^/mail-signature/images/([^/]+)$'); @@ -185,6 +205,18 @@ class FakeComposeBackend { 'pageInfo': {'hasNext': false, 'nextCursor': null}, }); }); + server.on('POST', ComposeApiPaths.mailAttachments, _upload); + server.on('GET', ComposeApiPaths.mailQuota, (_) async { + final Completer? gate = quotaGate; + if (gate != null) { + quotaGate = null; + await gate.future; + } + final Map? current = quota; + return current == null + ? FakeResponse.fail(503, 'MAIL_001') + : FakeResponse.ok(current); + }); server.onPattern('GET', _filePath, (RequestOptions options) async { final Completer? gate = largeFileGate; if (gate != null) { @@ -246,6 +278,27 @@ class FakeComposeBackend { }); } + Future _upload(RequestOptions options) async { + final MultipartFile file = (options.data! as FormData).files.single.value; + final String name = file.filename ?? ''; + uploads.add(( + name: name, + type: file.contentType.toString(), + length: file.length, + )); + if (uploadGates.isNotEmpty) await uploadGates.removeAt(0).future; + if (uploadFailures.isNotEmpty) return uploadFailures.removeAt(0); + return FakeResponse(201, { + 'success': true, + 'data': attachmentJson( + 'up-${++_nextUpload}', + name: serverFileName(name), + type: file.contentType.toString(), + size: uploadedSize ?? file.length, + ), + }); + } + Future _send(RequestOptions options) async { final Map body = options.data! as Map; sends.add(( @@ -296,3 +349,29 @@ class FakeComposeBackend { }); } } + +/// 첨부 한도 응답(`MailQuota`) — 기본은 서버 기본값(20 · 25MB) +Map quotaJson({ + int maxBytes = 20 * 1024 * 1024, + int totalMaxBytes = 25 * 1024 * 1024, +}) => { + 'usedBytes': 0, + 'limitBytes': null, + 'attachmentMaxBytes': maxBytes, + 'attachmentTotalMaxBytes': totalMaxBytes, +}; + +/// 서버가 받은 첨부 이름을 줄이는 모양 — 백엔드 `shared/utils/file-name.ts` 의 `sanitizeFileName` 처럼 255 코드 포인트까지, 확장자는 남긴다 +String serverFileName(String name) { + const int maxLength = 255; + final List runes = name.runes.toList(); + if (runes.length <= maxLength) return name; + final int dot = name.lastIndexOf('.'); + final List extension = dot > 0 + ? name.substring(dot).runes.toList() + : []; + return String.fromCharCodes([ + ...runes.take(maxLength - extension.length), + ...extension, + ]); +} diff --git a/flutter/test/support/fakes.dart b/flutter/test/support/fakes.dart index f163c52..1e171eb 100644 --- a/flutter/test/support/fakes.dart +++ b/flutter/test/support/fakes.dart @@ -227,6 +227,14 @@ class FakeServer implements HttpClientAdapter { /// 응답의 `Date` 머리(서버 시각) — null 이면 싣지 않는다 DateTime? serverTime; + /// 켜면 처리기를 부르기 전에 요청 본문을 끝까지 읽어 [requestBodies] 에 둔다 — 보내는 진행(onSendProgress)이 오고, + /// multipart 본문을 바이트로 볼 수 있다(꺼 두면 본문은 `RequestOptions.data` 로만 본다) + bool readRequestBodies = false; + + /// 읽은 요청 본문 — [readRequestBodies] 를 켠 동안 받은 차례대로 + final List<({String path, List body})> requestBodies = + <({String path, List body})>[]; + /// 경로 꼴마다 처리기 — 같은 경로의 [on] 이 없을 때 먼저 맞는 것 final List<({String method, RegExp path, FakeHandler handler})> _patterns = <({String method, RegExp path, FakeHandler handler})>[]; @@ -266,6 +274,11 @@ class FakeServer implements HttpClientAdapter { ) async { // 받은 그때의 모습을 남긴다 — 인터셉터가 실패를 내보내며 토큰 · 비밀 본문을 지워도 시험이 볼 수 있게 requests.add(options.copyWith()); + if (readRequestBodies && requestStream != null) { + final List body = []; + await requestStream.forEach(body.addAll); + requestBodies.add((path: options.path, body: body)); + } final FakeHandler? handler = _handlerOf(options); final FakeResponse response = handler == null ? FakeResponse.fail(404, 'RES_001') diff --git a/flutter/test/support/file_fakes.dart b/flutter/test/support/file_fakes.dart index 6b3ea07..9eb9d96 100644 --- a/flutter/test/support/file_fakes.dart +++ b/flutter/test/support/file_fakes.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:arasystem_app/core/files/downloaded_files.dart'; import 'package:arasystem_app/core/files/file_names.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; /// 메모리 파일 보관 — 시험에서는 기기 파일 시스템을 쓰지 않는다(시험 시계 안에서 파일 입출력이 끝나지 않는다) class MemoryFileStore implements DownloadedFileStore { @@ -94,3 +95,103 @@ class RecordingLinkLauncher { return result; } } + +/// 고른 파일의 가짜 — 고르기의 결과를 차례대로 준다([willPick] · [results] — 비면 고르지 않고 닫은 것). +/// 복사본은 메모리(경로마다 바이트)다 — 시험 시계 안에서는 기기 파일 입출력이 끝나지 않는다 +class FakePickedFiles implements PickedFileStore { + /// 다음 고르기들의 결과 — 차례대로 하나씩 쓴다 + final List results = []; + + /// 받은 고르기 — 넘겨받은 한도들(남은 개수 · 파일당 · 남은 합계 · 허용 확장자) + final List< + ({int maxBytes, int maxCount, int maxTotalBytes, List extensions}) + > + picks = + < + ({ + int maxBytes, + int maxCount, + int maxTotalBytes, + List extensions, + }) + >[]; + + /// 있으면 고르기가 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) — 고르는 창이 열려 있는 동안 + Completer? pickGate; + + /// 복사본 — 경로마다 바이트 + final Map> copies = >{}; + + /// 지운 복사본 차례대로 + final List deleted = []; + int purgeCount = 0; + int _next = 0; + + /// 고른 파일 하나 — 복사본을 두고 그 [PickedFile] 을 준다 + PickedFile copy(String name, List bytes) { + final String path = '/cache/$kPickedFilesFolder/${++_next}/$name'; + copies[path] = bytes; + return PickedFile( + name: name, + status: PickedFileStatus.ok, + size: bytes.length, + path: path, + ); + } + + /// 다음 고르기에서 이 파일들을 고른다 — 기기가 미리 거른 결과(`MainActivity.kt` 의 `copyPicked`)를 그대로 적는다 + void willPick(List files) => + results.add(PickResult(PickOutcome.picked, files)); + + @override + Future pick({ + required int maxBytes, + required int maxCount, + required int maxTotalBytes, + required List extensions, + }) async { + picks.add(( + maxBytes: maxBytes, + maxCount: maxCount, + maxTotalBytes: maxTotalBytes, + extensions: extensions, + )); + final Completer? gate = pickGate; + if (gate != null) { + pickGate = null; + await gate.future; + } + return results.isEmpty + ? const PickResult(PickOutcome.canceled) + : results.removeAt(0); + } + + @override + Future exists(String path) async => copies.containsKey(path); + + /// 조각 넷으로 흘린다 — 보내는 진행이 여러 번 온다 + @override + Stream> openRead(String path) { + final List? bytes = copies[path]; + if (bytes == null) { + return Stream>.error(const FileSystemException('없음(시험)')); + } + final int step = (bytes.length / 4).ceil().clamp(1, bytes.length + 1); + return Stream>.fromIterable(>[ + for (int start = 0; start < bytes.length; start += step) + bytes.sublist(start, (start + step).clamp(0, bytes.length)), + ]); + } + + @override + Future delete(String path) async { + deleted.add(path); + copies.remove(path); + } + + @override + Future purge() async { + purgeCount++; + copies.clear(); + } +} diff --git a/flutter/test/support/mail_harness.dart b/flutter/test/support/mail_harness.dart index dab2e99..6306e51 100644 --- a/flutter/test/support/mail_harness.dart +++ b/flutter/test/support/mail_harness.dart @@ -1,6 +1,7 @@ import 'package:arasystem_app/app.dart'; import 'package:arasystem_app/core/api/api_paths.dart'; import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; import 'package:arasystem_app/core/router/app_router.dart'; import 'package:arasystem_app/core/utils/provider_retry.dart'; import 'package:arasystem_app/features/mail/data/mail_link_launcher.dart'; @@ -27,7 +28,7 @@ const Size kPhoneSize = Size(390, 844); /// 메일 화면 시험대 — 로그인한 앱 전체를 가짜 서버(인증 + 메일)로 띄운다. /// /// 기기에 기대는 것은 가짜로 바꾼다: WebView([webView]), 받은 파일 보관([files]) · 다른 앱으로 열기([opener]), -/// 바깥 브라우저([links]). +/// 바깥 브라우저([links]), 고른 파일([picked] — 고르는 창 · 복사본). class MailAppHarness { MailAppHarness(); @@ -37,6 +38,10 @@ class MailAppHarness { final MemoryFileStore files = MemoryFileStore(); final RecordingFileOpener opener = RecordingFileOpener(); final RecordingLinkLauncher links = RecordingLinkLauncher(); + final FakePickedFiles picked = FakePickedFiles(); + + /// 고른 파일을 두는 곳 — 처음은 [picked](가짜). 기기 채널 쪽 규칙(시한 등)을 보는 시험은 [pump] 전에 바꿔 끼운다 + late PickedFileStore pickedStore = picked; /// 시험마다 더하는 덮어쓰기 — [pump] 전에 채운다 final List overrides = []; @@ -82,6 +87,7 @@ class MailAppHarness { downloadedFileStoreProvider.overrideWithValue(files), fileOpenerProvider.overrideWithValue(opener), mailLinkLauncherProvider.overrideWithValue(links.call), + pickedFileStoreProvider.overrideWithValue(pickedStore), ...overrides, ], child: const App(),