feat(flutter): 앱 첫 판 화면 — 로그인 · 메일 목록 · 읽기 · 쓰기 · 검색 · 새 메일 알림 #21

Merged
ttipo merged 34 commits from feat/app-push into main 2026-11-08 16:34:38 +09:00
27 changed files with 4360 additions and 136 deletions
Showing only changes of commit 3a126bdf83 - Show all commits
@@ -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<String>,
)
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<String>("path"), call.argument<String>("type")),
)
PICK_METHOD -> pickFiles(
limitsOf(
call.argument<Number>(ARG_MAX_BYTES),
call.argument<Number>(ARG_MAX_COUNT),
call.argument<Number>(ARG_MAX_TOTAL_BYTES),
call.argument<List<*>>(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<String>().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<Map<String, Any?>>()
val reply: Map<String, Any?> = 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<Uri> {
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<Uri>, limits: PickLimits, files: MutableList<Map<String, Any?>>) {
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<String, Any?>, name: String?, bytes: Long, limits: PickLimits): Map<String, Any?> {
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<String?, Long?> {
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<String, Any?> {
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<String, Any?> =
mapOf(KEY_NAME to name, KEY_SIZE to size, KEY_STATUS to status, KEY_PATH to path)
private fun outcomeOf(outcome: String): Map<String, Any?> = 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()
}
}
+21 -2
View File
@@ -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;
+330
View File
@@ -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<Object?, Object?>) {
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 <PickedFile>[]]);
final PickOutcome outcome;
/// 고른 차례대로 — [PickOutcome.picked] 일 때만
final List<PickedFile> files;
/// 채널의 답 — 모양이 틀리면 실패
static PickResult parse(Object? raw) {
if (raw is! Map<Object?, Object?>) {
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<Object?>) return const PickResult(PickOutcome.failed);
return PickResult(
PickOutcome.picked,
List<PickedFile>.unmodifiable(<PickedFile>[
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<PickResult> pick({
required int maxBytes,
required int maxCount,
required int maxTotalBytes,
required List<String> extensions,
});
/// 복사본이 아직 있는가 — 복사본 폴더 밖의 경로는 거짓(기기가 임시 폴더를 비웠을 수 있다)
Future<bool> exists(String path);
/// 복사본을 읽는다(올리기 — 부를 때마다 처음부터)
Stream<List<int>> openRead(String path);
/// 복사본 하나를 그 폴더째 지운다 — 복사본 폴더 밖은 지우지 않는다
Future<void> delete(String path);
/// 모두 지운다
Future<void> purge();
}
/// 고른 파일을 두는 곳 — main 이 앱을 띄울 때 정리한 것으로 덮어쓴다
final Provider<PickedFileStore> pickedFileStoreProvider =
Provider<PickedFileStore>((Ref ref) => PlatformPickedFileStore());
/// 안드로이드 고르는 창(`MainActivity.kt` 의 파일 채널 `pick`) + 앱 임시 폴더의 [kPickedFilesFolder].
///
/// 고르기와 모두 지우기는 부른 차례대로 하나씩 한다 — 고르는 동안 직원이 바뀌어 지우기가 들어오면 고른 복사본까지 지운다
/// (앱을 띄울 때의 정리가 막 고른 복사본을 지우지 않게도).
class PlatformPickedFileStore implements PickedFileStore {
PlatformPickedFileStore({
Future<Directory> 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<Directory> Function() _baseDirectory;
final MethodChannel _channel;
/// 고르기 한 번의 시한 — [kPickTimeout](시험은 짧게)
final Duration _pickTimeout;
Future<void> _queue = Future<void>.value();
Future<T> _serial<T>(Future<T> Function() task) {
final Future<T> next = _queue.then((_) => task());
_queue = next.then<void>((_) {}, onError: (Object _) {});
return next;
}
/// 복사본 폴더 — 임시 폴더를 알 수 없으면(플러그인 · 기기 사정) null
Future<Directory?> _root() async {
try {
final Directory base = await _baseDirectory();
return Directory('${base.path}/$kPickedFilesFolder');
} on Exception {
return null;
}
}
@override
Future<PickResult> pick({
required int maxBytes,
required int maxCount,
required int maxTotalBytes,
required List<String> extensions,
}) => _serial(() async {
try {
// 인자 이름 — 짝: `MainActivity.kt` 의 `ARG_*`. 시한은 차례 안에서 잰다(넘으면 다음 차례로)
final Object? raw = await _channel
.invokeMethod<Object?>(pickMethod, <String, Object?>{
'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<Directory?> _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<bool> exists(String path) async {
if (await _copyFolderOf(path) == null) return false;
return File(path).exists();
}
@override
Stream<List<int>> openRead(String path) => File(path).openRead();
@override
Future<void> delete(String path) async {
final Directory? folder = await _copyFolderOf(path);
if (folder == null) return;
try {
await folder.delete(recursive: true);
} on FileSystemException {
// 이미 없다 — 다음 정리 · 기기가 지운다
}
}
@override
Future<void> purge() => _serial(() async {
final Directory? root = await _root();
if (root == null) return;
try {
if (await root.exists()) await root.delete(recursive: true);
} on FileSystemException {
// 다음 정리 · 기기가 지운다
}
});
}
+12 -1
View File
@@ -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;
+10 -1
View File
@@ -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;
@@ -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<String, Object?>) 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);
}
@@ -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<MessageAttachment> uploadAttachment({
required String name,
required String type,
required int length,
required Stream<List<int>> Function() read,
required CancelToken cancelToken,
required void Function(int sent, int total) onProgress,
}) async {
final FormData form = FormData()
..files.add(
MapEntry<String, MultipartFile>(
kAttachmentFileField,
MultipartFile.fromStream(
read,
length,
filename: name,
contentType: DioMediaType.parse(type),
),
),
);
final Response<Object?> response = await _dio.post<Object?>(
ComposeApiPaths.mailAttachments,
data: form,
options: Options(
sendTimeout: kAttachmentUploadSendTimeout,
receiveTimeout: kAttachmentUploadReceiveTimeout,
extra: <String, Object?>{kSilentExtraKey: true},
),
cancelToken: cancelToken,
onSendProgress: onProgress,
);
final MessageAttachment? attachment = MessageAttachment.tryParse(
response.data,
);
if (attachment == null) throw _malformed(response);
return attachment;
}
/// 첨부 한도(파일당 · 합계) — 알리지 않는다(받지 못하면 대체값으로 쓴다)
Future<AttachmentLimits> getAttachmentLimits() async {
final Response<Object?> response = await _dio.get<Object?>(
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: <String, Object?>{if (silent) kSilentExtraKey: true},
@@ -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<String, String> kMailAttachmentTypes = <String, String>{
'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;
}
@@ -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<String> kMailAttachmentExtensions = <String>{
'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<String> names) {
@@ -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`). 보드에 없는 상태(붙잡힌 메일 · 본문 안내 · 받지 못함 · 첨부 열기 · 삭제 확인)의 글은
@@ -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<MessageComposePage> {
/// 창을 열 때의 저장 차례 — 이보다 오르면 그사이 저장이 끝난 것이다
int _askedSaveCount = 0;
/// 올리는 중 나가기 확인 창이 열려 있다 — 그사이 모두 다 올렸으면 창을 닫고 떠난다
bool _askingUpload = false;
/// 창을 열 때 올리지 못한 고른 파일의 수 — 묻는 사이 늘었으면(마지막 올리기가 실패) 저절로 떠나지 않고 답을 기다린다
int _askedUploadFailures = 0;
/// 펼친 원본 메일의 줄 — 인용이 바뀔 때만 다시 만든다
ComposeQuote? _linesQuote;
List<QuoteDisplayLine> _lines = const <QuoteDisplayLine>[];
@@ -127,6 +136,21 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
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<MessageComposePage> {
/// ✕ · 막힌 뒤로 가기 — 위 머리말의 차례. 떠나기 전 저장을 기다리는 동안은 [보내기]를 막는다
Future<void> _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<MessageComposePage> {
}
}
/// 올리는 중 나가기 확인 — [계속 쓰기]가 처음 초점이자 주 단추. 묻는 동안 모두 다 올리면 저절로 [나가기]로 닫힌다([_onStateChanged])
Future<bool> _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<MessageComposePage> {
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<MessageComposePage> {
onRetry: () => unawaited(_controller.retry()),
onLeave: () => unawaited(_leave()),
),
ComposeStatus.ready => _form(context, state),
ComposeStatus.ready => Stack(
fit: StackFit.expand,
children: <Widget>[
_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<MessageComposePage> {
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<MessageComposePage> {
}
}
/// 올리지 못한 고른 파일의 수
int failedUploadsOf(ComposeState state) => state.attachments
.where(
(ComposeAttachment item) => item.upload == ComposeUploadStatus.failed,
)
.length;
/// 위 막대의 제목 — 쓰기 종류(저장본을 받기 전에는 "메일 쓰기")
String composeTitleOf(ComposeState state) {
if (!state.titleKnown) return MailComposeTexts.loadingTitle;
@@ -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<MessageAttachment> Function({
required String name,
required String type,
required int length,
required Stream<List<int>> 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<int, _UploadJob> _jobs = <int, _UploadJob>{};
/// 차례를 기다리는 파일 — 고른 차례대로
final List<int> _waiting = <int>[];
/// 올리는 중인 파일과 그 취소 표
final Map<int, CancelToken> _running = <int, CancelToken>{};
/// 마지막으로 알린 비율 — 같은 비율을 거듭 알리지 않는다
final Map<int, int> _percents = <int, int>{};
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(<int>[key]);
/// 여럿을 뺀다 — 다 뺀 뒤에 다음 차례를 시작한다(뺄 파일이 그사이 시작하지 않게)
void removeAll(Iterable<int> 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<void> _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();
}
}
@@ -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<DraftSuccessors> draftSuccessorsProvider =
return DraftSuccessors();
});
/// 고른 파일의 복사본을 두는 곳 — 메일을 보는 직원이 바뀌면(로그아웃 · 세션 끝 · 다른 직원) 모두 지운다(받은 파일과 같은 결 — `mailDownloadedFilesProvider`).
/// 앱이 살아 있는 동안 둔다. 지난 실행이 남긴 복사본은 main 이 앱을 띄울 때 지운다(`PickedFileStore.purge`)
final Provider<PickedFileStore> composePickedFilesProvider =
Provider<PickedFileStore>((Ref ref) {
final PickedFileStore store = ref.watch(pickedFileStoreProvider);
ref.listen<String?>(mailAccountProvider, (
String? previous,
String? next,
) {
if (previous != next) unawaited(store.purge());
});
return store;
});
/// 옛 저장본 id → 새 id (웹 `mail.store` 의 `recordDraftSuccessor` · `latestDraftId`)
class DraftSuccessors {
final Map<String, String> _next = <String, String>{};
@@ -120,6 +139,10 @@ const Set<String> kComposeInlineCodes = <String>{
'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<ComposeState> {
/// 웹 · 다른 프로그램이 만든 저장본 — 본문을 고치지 않으면 원래 HTML 그대로 보낸다
ForeignDraftBody? _foreign;
int _nextAttachmentKey = 0;
/// 고른 파일의 복사본 · 올리기 차례
late final PickedFileStore _files;
late final AttachmentUploadQueue _uploads;
/// 첨부 한도를 받는 일 — 전달 원본 · 저장본의 첨부를 검사하기 전에 잠깐 기다린다(받지 못하거나 늦으면 대체값)
Future<void> _limitsLoaded = Future<void>.value();
/// `MAIL_004` 로 저절로 다시 올린 줄 — 그것을 실은 저장이 성공하기 전에 또 `MAIL_004` 면 다시 올리지 않는다(되풀이를 막는다)
final Set<int> _reuploaded = <int>{};
/// 서버가 잃은(`MAIL_004`) 고른 파일 — 다시 올리기에 성공하거나 뺄 때까지, 올리지 못하면 "찾지 못함"으로 보내기 · 저장을 막는다.
/// 이미 이 메일에 실렸던 첨부라 모르는 사이 빠진 채 나가지 않게(웹 `markMissing` — 웹은 다시 올리지 않고 빼기만)
final Set<int> _lostOnServer = <int>{};
int? _undoSendSeconds;
bool _savedOnce = false;
@@ -262,6 +303,16 @@ class ComposeController extends Notifier<ComposeState> {
_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<ComposeState> {
_preloadSuggestions();
_preloadGroups();
unawaited(_loadUndoSeconds());
_limitsLoaded = _loadLimits();
final ComposeState initial;
switch (intent) {
@@ -374,6 +426,7 @@ class ComposeController extends Notifier<ComposeState> {
state = ComposeState(
kind: state.kind,
titleKnown: args.intent is! DraftComposeIntent,
limits: state.limits,
);
await _loadSource(_formGeneration);
}
@@ -429,6 +482,8 @@ class ComposeController extends Notifier<ComposeState> {
signatureText: prepared?.text,
),
);
// 전달하는 원본 첨부의 합계는 서버 한도로 잰다(받지 못하거나 늦으면 대체값) — 잴 첨부가 있을 때만 기다린다(답장은 기다리지 않는다)
if (forward && detail.attachments.isNotEmpty) await _waitLimits();
if (!_alive(generation)) return;
List<RecipientToken> to = const <RecipientToken>[];
List<RecipientToken> cc = const <RecipientToken>[];
@@ -491,6 +546,8 @@ class ComposeController extends Notifier<ComposeState> {
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<ComposeState> {
size: attachment.size,
attachedCount: count,
attachedBytes: bytes,
totalMaxBytes: state.limits.totalMaxBytes,
);
if (rejected == null) {
count++;
@@ -598,6 +656,21 @@ class ComposeController extends Notifier<ComposeState> {
}
}
/// 첨부 한도를 [kAttachmentLimitsWait] 까지 기다린다 — 넘으면 그대로 간다(받으면 뒤에 바뀐다)
Future<void> _waitLimits() =>
_limitsLoaded.timeout(kAttachmentLimitsWait, onTimeout: () {});
/// 첨부 한도(파일당 · 합계) — 받지 못하면 대체값(20 · 25MB)으로 쓴다
Future<void> _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<ComposeState> {
_emitUnsaved();
}
/// 첨부를 뺀다 — 고른 파일이면 올리던 것을 끊고(✕ = 올리기 취소) 복사본을 지운다
void removeAttachment(int key) {
if (!ref.mounted) return;
_uploads.remove(key);
_reuploaded.remove(key);
_lostOnServer.remove(key);
state = state.copyWith(
attachments: <ComposeAttachment>[
for (final ComposeAttachment item in state.attachments)
@@ -898,6 +975,264 @@ class ComposeController extends Notifier<ComposeState> {
_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<void> 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<PickedFile> 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<AttachmentRejection, List<String>> rejected =
<AttachmentRejection, List<String>>{};
final List<ComposeAttachment> added = <ComposeAttachment>[];
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] ??= <String>[])
.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<ComposeAttachment>.unmodifiable(<ComposeAttachment>[
...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<int> dropped = <int>{
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: <ComposeAttachment>[
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<ComposeAttachment> 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<ComposeAttachment>.unmodifiable(
List<ComposeAttachment>.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<ComposeState> {
_emitUnsaved();
}
/// [보내기]를 막는 까닭 — 빼야 하는 파일이 먼저(위험 · 지움 · 찾지 못한 첨부), 그다음 웹에서 올리다 멈춘 파일. 막지 않으면 null
/// [보내기]를 막는 까닭 — 빼야 하는 파일이 먼저(위험 · 지움 · 찾지 못한 첨부), 그다음 웹에서 올리다 멈춘 파일, 마지막으로 올리는 중 · 가져오는 중인 첨부
/// (웹 `sendBlockOf` 와 같은 차례 — 가져오는 중은 앱에서 더함: 드라이브 파일을 받는 사이 누르면 그 파일 없이 나가지 않게). 막지 않으면 null
String? get sendBlockReason {
final List<DraftLargeFile> large = state.largeFiles;
if (large.any((DraftLargeFile f) => f.infected)) {
@@ -929,6 +1265,8 @@ class ComposeController extends Notifier<ComposeState> {
)) {
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<ComposeState> {
(DraftLargeFile f) => f.status == DraftLargeFileStatus.missing,
);
/// 서버가 첨부를 찾지 못했다(`MAIL_004`) — 어느 것인지 모르니 싣던 첨부를 모두 "찾지 못함"으로(웹과 같다)
/// 서버가 첨부를 찾지 못했다(`MAIL_004`) — 어느 것인지 모르니 싣던 첨부를 모두 손본다(웹 `markMissing` 과 같은 결):
/// 이 화면에서 고른 파일은 복사본으로 한 번 다시 올리고, 다시 올릴 수 없으면(이미 한 번 다시 올림 · 화면이 닫혀 차례가 닫힘)
/// 원본 · 저장본의 첨부처럼 "찾지 못함"으로 둔다 — 빼기 전까지 저장 · 보내기를 막는다(모르는 사이 첨부 없이 나가지 않게).
/// 다시 올리다 실패하면(복사본이 없어짐 등) 그때 "찾지 못함"을 붙인다([_onUploadFailed])
void _markAttachmentsMissing() {
// 다시 올릴 줄 — 그 전의 모습(다시 올리지 못하면 그 모습에 "찾지 못함"을 붙인다)
final Map<int, ComposeAttachment> reupload = <int, ComposeAttachment>{};
final List<ComposeAttachment> next = <ComposeAttachment>[];
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: <ComposeAttachment>[
for (final ComposeAttachment item in state.attachments)
item.sendable
? item.copyWith(problem: ComposeAttachmentProblem.missing)
: item,
],
attachments: List<ComposeAttachment>.unmodifiable(next),
);
for (final MapEntry<int, ComposeAttachment> 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<ComposeState> {
(TextEditingController input) => input.text.trim().isNotEmpty,
);
/// 떠나면 잃는 것이 있다 — 저장하지 않은 변경 · 칸에 쓰다 만 주소 · 찾지 못한 첨부(그동안은 저장하지 않는다)
/// 떠나면 잃는 것이 있다 — 저장하지 않은 변경 · 칸에 쓰다 만 주소 · 찾지 못한 첨부(그동안은 저장하지 않는다) · 올리는 중인 첨부
bool get hasUnsavedChanges {
if (!ref.mounted ||
_ended ||
@@ -1022,7 +1384,8 @@ class ComposeController extends Notifier<ComposeState> {
return _hasDraftChanges() ||
_typingRecipient ||
_hasMissingAttachments ||
_hasMissingLargeFiles;
_hasMissingLargeFiles ||
state.uploading;
}
void _emitUnsaved() {
@@ -1146,6 +1509,9 @@ class ComposeController extends Notifier<ComposeState> {
if (!_alive(generation)) return;
_baseline = snapshot;
_savedOnce = true;
_reuploaded.removeAll(<int>[
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<ComposeState> {
if (!_attached) return;
_attached = false;
_cancelSearch();
// 올리던 첨부는 그만두고 복사본을 지운다 — 떠나기 전 저장은 다 올린 첨부만 실었다(보내기는 올리는 동안 막혀 있다)
_uploads.close();
if (!_sent && !_sendingNow) _autosave.stop();
final Set<String> removed = <String>{..._replacedIds};
if ((removed.isNotEmpty || _savedOnce) && !_ended && ref.mounted) {
@@ -1536,6 +1904,7 @@ class ComposeController extends Notifier<ComposeState> {
_ended = true;
_autosave.stop();
_cancelSearch();
_uploads.close();
if (!ref.mounted) return;
// 잃을 것이 없다고 본다 — 화면이 묻지 않고 닫히게(다른 직원의 임시보관함에 남기지 않는다). 나가기 확인 창도 알리지 않고 닫는다
state = state.copyWith(ended: true);
@@ -1545,6 +1914,7 @@ class ComposeController extends Notifier<ComposeState> {
void _dispose() {
_autosave.dispose();
_cancelSearch();
_uploads.close();
subject.dispose();
body.dispose();
for (final TextEditingController input in inputs.values) {
@@ -1578,6 +1948,31 @@ Future<void> _undoSend(
}
}
/// 고른 파일 가운데 넣지 못한 것의 알림 — 까닭마다 한 문장, 처음 나온 차례대로 줄을 바꿔 잇는다(알림은 한 번에 하나만 보인다).
/// 개수 · 형식은 사전 문구(`MAIL_006` · `FILE_001`), 크기는 서버 한도로 쓴 글, 합계는 넣지 못한 첫 파일 이름(여럿이면 "외 N개")
String pickRejectionNotice(
Map<AttachmentRejection, List<String>> rejected,
AttachmentLimits limits,
) => <String>[
for (final MapEntry<AttachmentRejection, List<String>> 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,
@@ -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<String> _finalCodes = <String>{
'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<RecipientToken> 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,
);
}
@@ -26,7 +26,7 @@ class ComposeAppBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
final VoidCallback onClose;
/// [파일 첨부] — null 이면 꺼진 단추(받는 중 · 받지 못함 · 보내는 중)
/// [파일 첨부](고르는 창) — null 이면 꺼진 단추(받는 중 · 받지 못함 · 보내는 중 · 고르는 중 · 나가는 중)
final VoidCallback? onAttach;
/// [보내기] — null 이면 꺼진 단추(받는 중 · 받지 못함)
@@ -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<ComposeAttachment> attachments;
final List<DraftLargeFile> largeFiles;
/// 머리 오른쪽의 한도 글 — 서버 값(받지 못하면 20 · 25MB)
final AttachmentLimits limits;
final ValueChanged<int> onRemoveAttachment;
/// 올리지 못한 첨부의 [다시 시도]
final ValueChanged<int> onRetryAttachment;
final ValueChanged<String> onRemoveLargeFile;
/// 거짓이면 ✕ 를 막는다(보내는 중)
/// 거짓이면 ✕ · [다시 시도]를 막는다(보내는 중)
final bool enabled;
@override
@@ -73,35 +84,42 @@ class ComposeAttachmentsSection extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Semantics(
header: true,
child: Text(
MailComposeTexts.attachmentsTitle(
attachments.length + largeFiles.length,
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: <Widget>[
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<String>('compose-attachment-limits'),
textAlign: TextAlign.end,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.regular,
color: extra.textMuted,
),
),
),
),
],
),
for (final ComposeAttachment item in attachments) ...<Widget>[
const SizedBox(height: AppSpacing.s8),
_ComposeFileRow(
key: ValueKey<String>('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) ...<Widget>[
const SizedBox(height: AppSpacing.s8),
@@ -109,11 +127,16 @@ class ComposeAttachmentsSection extends StatelessWidget {
key: ValueKey<String>('compose-large-file-${file.id}'),
icon: Icons.cloud_outlined,
name: file.name ?? MailComposeTexts.largeUnknownName,
detail: <String>[
if (file.size case final int size) formatFileSize(size),
MailReaderTexts.largeFile,
].join(' · '),
below: _DetailText(
<String>[
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<String>('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<AppExtraColors>()!;
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<FontFeature> _tabular = <FontFeature>[
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: <Widget>[
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<AppExtraColors>()!;
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: <Widget>[
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,
),
+6 -1
View File
@@ -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<void> 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<void> main() async {
userAgentProvider.overrideWithValue(device.userAgent),
appVersionProvider.overrideWithValue(device.appVersion),
downloadedFileStoreProvider.overrideWithValue(downloadedFiles),
pickedFileStoreProvider.overrideWithValue(pickedFiles),
],
child: const App(),
),
@@ -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<MethodCall> calls = <MethodCall>[];
/// 채널의 답 — 시험이 바꿔 끼운다
late Future<Object?> 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 => <String, Object?>{
'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<String> copyOf(String folder, String name, List<int> 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(<Object?, Object?>{
'outcome': 'picked',
'files': <Object?>[
<Object?, Object?>{
'name': '견적서.pdf',
'size': 3,
'status': 'ok',
'path': '/c/uploads/a/견적서.pdf',
},
<Object?, Object?>{
'name': '영상.mp4',
'size': 900000000,
'status': 'tooLarge',
'path': null,
},
<Object?, Object?>{
'name': 'c.png',
'size': null,
'status': 'skipped',
},
<Object?, Object?>{
'name': 'IMG_0001.HEIC',
'size': 3,
'status': 'notAllowed',
},
<Object?, Object?>{
'name': '도면.pdf',
'size': 9,
'status': 'overTotal',
},
<Object?, Object?>{'name': null, 'status': 'failed'},
],
});
expect(result.outcome, PickOutcome.picked);
expect(result.files, <PickedFile>[
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(<Object?, Object?>{
'outcome': 'picked',
'files': <Object?>[
<Object?, Object?>{'name': 'a.pdf', 'size': 3, 'status': 'ok'},
<Object?, Object?>{
'name': 'b.pdf',
'status': 'ok',
'path': '/c/uploads/b/b.pdf',
},
<Object?, Object?>{'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(<Object?, Object?>{'outcome': 'canceled'}),
PickOutcome.canceled,
);
expect(
outcomeOf(<Object?, Object?>{'outcome': 'busy'}),
PickOutcome.busy,
);
expect(
outcomeOf(<Object?, Object?>{'outcome': 'noApp'}),
PickOutcome.noApp,
);
expect(
outcomeOf(<Object?, Object?>{'outcome': 'failed'}),
PickOutcome.failed,
);
expect(outcomeOf(<Object?, Object?>{'outcome': '?'}), PickOutcome.failed);
expect(outcomeOf(null), PickOutcome.failed);
expect(
outcomeOf(<Object?, Object?>{'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 => <String, Object?>{
'outcome': 'picked',
'files': <Object?>[
<String, Object?>{
'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: <String>['pdf', 'hwp'],
);
expect(calls.single.method, PlatformPickedFileStore.pickMethod);
expect(calls.single.arguments, <String, Object?>{
'maxBytes': 20,
'maxCount': 3,
'maxTotalBytes': 25,
'extensions': <String>['pdf', 'hwp'],
});
expect(result.files.single.path, '${rootPath()}/x/a.pdf');
});
test(
'기기가 답하지 않으면 시한 뒤 timedOut 으로 끝내고 차례를 넘긴다 — 다음 고르기 · 정리가 묶이지 않는다',
() async {
// 고른 앱이 읽기에서 멈춰 기기가 답하지 않는다 — 첫 고르기의 답은 오지 않는다
final Completer<Object?> stuck = Completer<Object?>();
answer = (MethodCall call) => calls.length == 1
? stuck.future
: Future<Object?>.value(<String, Object?>{'outcome': 'canceled'});
final PlatformPickedFileStore quick = PlatformPickedFileStore(
baseDirectory: () async => base,
pickTimeout: const Duration(milliseconds: 20),
);
Future<PickResult> pick() => quick.pick(
maxBytes: 1,
maxCount: 1,
maxTotalBytes: 1,
extensions: <String>['pdf'],
);
expect((await pick()).outcome, PickOutcome.timedOut);
expect((await pick()).outcome, PickOutcome.canceled);
expect(calls, hasLength(2));
await copyOf('f1', 'a.pdf', <int>[1]);
await quick.purge();
expect(await Directory(rootPath()).exists(), isFalse);
// 늦게 온 답은 버린다(그 복사본은 다음 정리에 지워진다)
stuck.complete(<String, Object?>{'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: <String>['pdf'],
)).outcome,
PickOutcome.failed,
);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
expect(
(await store.pick(
maxBytes: 1,
maxCount: 1,
maxTotalBytes: 1,
extensions: <String>['pdf'],
)).outcome,
PickOutcome.failed,
);
});
test('모두 지우기는 열린 고르기가 끝난 뒤에 돈다 — 고르는 동안 들어온 정리가 막 고른 복사본까지 지운다', () async {
final Completer<Object?> picker = Completer<Object?>();
answer = (MethodCall call) => picker.future;
final Future<PickResult> picking = store.pick(
maxBytes: 9,
maxCount: 1,
maxTotalBytes: 9,
extensions: <String>['pdf'],
);
final Future<void> purging = store.purge();
await Future<void>.delayed(Duration.zero);
// 고르는 창이 열린 동안 복사본이 생긴다(Kotlin)
final String path = await copyOf('f1', 'a.pdf', <int>[1]);
bool purged = false;
unawaited(purging.then((_) => purged = true));
await Future<void>.delayed(Duration.zero);
expect(purged, isFalse);
expect(await File(path).exists(), isTrue);
picker.complete(<String, Object?>{'outcome': 'canceled'});
await picking;
await purging;
expect(await Directory(rootPath()).exists(), isFalse);
});
});
group('복사본', () {
test('있는가 · 읽기 · 지우기 — 복사본 폴더 바로 아래 한 칸만, 지우면 그 폴더째', () async {
final String path = await copyOf('f1', '견적서.pdf', <int>[1, 2, 3]);
final String other = await copyOf('f2', '견적서.pdf', <int>[4]);
expect(await store.exists(path), isTrue);
expect(
await store.openRead(path).expand((List<int> chunk) => chunk).toList(),
<int>[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 <File>[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', <int>[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');
});
});
}
@@ -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<MessageAttachment> done = Completer<MessageAttachment>();
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<String> events;
late AttachmentUploadQueue queue;
Future<MessageAttachment> upload({
required String name,
required String type,
required int length,
required Stream<List<int>> 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 = <String>[];
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<void> settle() => Future<void>.delayed(Duration.zero);
/// 고른 파일 하나를 차례에 넣는다 — 복사본은 가짜 보관
PickedFile add(int key, String name, [int size = 4]) {
final PickedFile file = files.copy(name, List<int>.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, <String>['start 1', 'start 2']);
expect(calls.map((_Call c) => c.name), <String>['a.pdf', '보고서.hwp']);
expect(calls[1].type, 'application/x-hwp');
calls[0].succeed('up-1');
await settle();
expect(events, <String>['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, <String>['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, <String>[first.path!]);
expect(queue.hasCopy(1), isFalse);
expect(events, <String>['start 1', 'start 2', 'start 3']);
expect(calls.map((_Call c) => c.name), <String>['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(<int>[1, 2, 3]);
await settle();
expect(events, <String>['start 1', 'start 2', 'start 4']);
expect(calls.map((_Call c) => c.name), <String>['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(<String>[
'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', <int>[1]);
files.copies.remove(file.path);
queue.add(1, path: file.path!, name: 'a.pdf', size: 1);
await settle();
expect(calls, isEmpty);
expect(events, <String>['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, <String>['start 1', 'start 2']);
add(4, 'd.pdf');
await settle();
expect(files.copies, isEmpty);
expect(events, hasLength(2));
expect(queue.requeue(1), isFalse);
});
}
@@ -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<String, String> backend = <String, String>{
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(<String, Object?>{
'usedBytes': 1,
'limitBytes': null,
'attachmentMaxBytes': 30 * _mb,
'attachmentTotalMaxBytes': 40.0 * _mb,
}),
const AttachmentLimits(maxBytes: 30 * _mb, totalMaxBytes: 40 * _mb),
);
expect(
AttachmentLimits.tryParse(<String, Object?>{'attachmentMaxBytes': 1}),
isNull,
);
expect(
AttachmentLimits.tryParse(<String, Object?>{
'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, List<String>>{
AttachmentRejection.overTotal: <String>['현장사진.jpg'],
}, limits),
'첨부를 모두 합치면 25MB를 넘어 ‘현장사진.jpg’은(는) 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.',
);
expect(
pickRejectionNotice(<AttachmentRejection, List<String>>{
AttachmentRejection.overTotal: <String>['a.jpg', 'b.jpg', 'c.jpg'],
}, limits),
'첨부를 모두 합치면 25MB를 넘어 ‘a.jpg’ 외 2개는 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.',
);
expect(
pickRejectionNotice(<AttachmentRejection, List<String>>{
AttachmentRejection.notAllowed: <String>['IMG_0001.HEIC', 'b.exe'],
AttachmentRejection.tooLarge: <String>['영상.mp4'],
AttachmentRejection.tooMany: <String>['z.pdf'],
AttachmentRejection.unreadable: <String>['드라이브.pdf'],
}, const AttachmentLimits(maxBytes: 30 * _mb, totalMaxBytes: 40 * _mb)),
<String>[
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 <String>['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));
});
});
}
@@ -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<String, Object?> created(
String id,
String name,
String type,
int size,
) => <String, Object?>{
'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<List<int>>.fromIterable(<List<int>>[
<int>[0x41, 0x42],
<int>[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<String, MultipartFile> 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<MessageAttachment> upload(CancelToken cancel) =>
repository.uploadAttachment(
name: 'a.pdf',
type: 'application/pdf',
length: 1,
read: () => Stream<List<int>>.value(<int>[1]),
cancelToken: cancel,
onProgress: (int sent, int total) {},
);
expect(
apiErrorOf(await errorOf(upload(CancelToken()))).code,
'FILE_001',
);
expect(h.notifier.shownCodes, isEmpty);
final Completer<void> gate = Completer<void>();
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<Object> 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, <String, Object?>{
'success': true,
'data': <String, Object?>{'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(<String, Object?>{
...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<List<int>>.value(<int>[7, 8, 9]);
},
cancelToken: CancelToken(),
onProgress: (int sent, int total) {},
);
expect(uploaded.id, 'att-3');
final List<({String path, List<int> body})> bodies = h
.server
.requestBodies
.where(
(({String path, List<int> 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(<String, Object?>{
'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(<String, Object?>{'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);
},
);
});
}
@@ -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';
@@ -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<String> 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, <int>[1, 2, 3, 4]);
return PickedFile(
name: name,
status: PickedFileStatus.ok,
size: size,
path: copy.path,
);
}
Future<void> attach(WidgetTester tester, List<PickedFile> files) async {
h.picked.willPick(files);
await tapTooltip(tester, MailComposeTexts.attach);
}
/// 다음 올리기 하나의 응답을 붙잡는다
Completer<void> holdUpload() {
final Completer<void> gate = Completer<void>();
c.uploadGates.add(gate);
return gate;
}
List<String> uploadedNames() => <String>[
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<void> first = holdUpload();
final Completer<void> second = holdUpload();
final Completer<void> third = holdUpload();
await attach(tester, <PickedFile>[
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(), <String>['견적서.pdf', '현장사진.jpg']);
expect(
c.uploads.map((({String name, String type, int length}) u) => u.type),
<String>['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<void> gate = holdUpload();
await attach(tester, <PickedFile>[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'], <Object?>[
<String, Object?>{'id': 'up-1', 'name': '견적서.pdf'},
]);
await tapTooltip(tester, MailComposeTexts.send);
expect(c.sends.single.body['attachments'], <Object?>[
<String, Object?>{'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, <PickedFile>[
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(
<String>[
ErrorLexicon.getMessage('FILE_001'),
'20MB보다 큰 파일은 앱에서 아직 보낼 수 없습니다. 웹메일에서 대용량 첨부로 보내 주세요.',
'파일을 가져오지 못했습니다. 다시 골라 주세요.',
ErrorLexicon.getMessage('MAIL_006'),
].join('\n'),
),
findsOneWidget,
);
expect(h.picked.deleted, <String>[heic.path!]);
expect(uploadedNames(), <String>['견적서.pdf']);
},
);
testWidgets('합계 한도 — 넘게 만드는 파일만 넣지 않는다(이름 + 은(는)) · 전달하는 원본의 합계도 서버 한도로 잰다', (
WidgetTester tester,
) async {
c.quota = quotaJson(totalMaxBytes: mb);
h.mail.put(
kInboxId,
messageJson('f3', mailboxId: kInboxId, subject: '자료', isRead: true),
detail: <String, Object?>{
'attachments': <Object?>[
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, <PickedFile>[
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<void> gate = Completer<void>();
c.quotaGate = gate;
h.mail.put(
kInboxId,
messageJson('f5', mailboxId: kInboxId, subject: '자료', isRead: true),
detail: <String, Object?>{
'attachments': <Object?>[
attachmentJson('a1', name: '견적서.pdf', size: 600 * 1024),
attachmentJson('a2', name: '도면.pdf', size: 600 * 1024),
],
},
);
await start(tester);
unawaited(h.router.push<void>(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: <Map<String, Object?>>[
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, <PickedFile>[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<void> third = holdUpload();
final PickedFile first = picked('a.pdf');
await attach(tester, <PickedFile>[
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(), <String>['a.pdf', 'b.pdf']);
await tapTooltip(tester, MailComposeTexts.cancelUpload('a.pdf'));
expect(find.text('a.pdf'), findsNothing);
expect(h.picked.deleted, <String>[first.path!]);
expect(uploadedNames(), <String>['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>[
FakeResponse.fail(503, 'MAIL_001'),
FakeResponse.fail(400, 'FILE_001'),
]);
await attach(tester, <PickedFile>[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(), <String>['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: <String, Object?>{
'attachments': <Object?>[attachmentJson('a1', name: '원본.pdf')],
},
);
await start(tester);
await open(tester, MailRoutes.composeForward('f4'));
await attach(tester, <PickedFile>[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(), <String>['추가.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'], <Object?>[
<String, Object?>{'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, <PickedFile>[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<void> gate = holdUpload();
await attach(tester, <PickedFile>[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'], <Object?>[
<String, Object?>{'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, <PickedFile>[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<void> gate = Completer<void>();
h.picked.pickGate = gate;
h.picked.willPick(<PickedFile>[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(), <String>['견적서.pdf']);
await tapTooltip(tester, MailComposeTexts.send);
expect(c.sends.single.body['attachments'], <Object?>[
<String, Object?>{'id': 'up-1', 'name': '견적서.pdf'},
]);
});
testWidgets('고른 결과가 화면을 떠난 뒤에 오면 붙이지 않고 복사본만 지운다', (
WidgetTester tester,
) async {
await start(tester);
await open(tester, MailRoutes.compose);
final Completer<void> gate = Completer<void>();
h.picked.pickGate = gate;
final PickedFile file = picked('견적서.pdf');
h.picked.willPick(<PickedFile>[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, <String>[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<void> gate = Completer<void>();
h.picked.pickGate = gate;
final PickedFile file = picked('견적서.pdf');
h.picked.willPick(<PickedFile>[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: <Map<String, Object?>>[
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, <PickedFile>[
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(), <String>['견적서.pdf', '도면.pdf']);
expect(
snack(
<String>[
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, <PickedFile>[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'], <Object?>[
<String, Object?>{'id': 'up-1', 'name': serverName},
]);
},
);
testWidgets('다시 올린 뒤 또 MAIL_004 면 "찾지 못함" — 빼기 전까지 보내기 · 저장을 막는다', (
WidgetTester tester,
) async {
await start(tester);
await open(tester, MailRoutes.compose);
await attach(tester, <PickedFile>[picked('추가.pdf')]);
await addRecipient(tester, 'doyun@x.kr');
c.sendFailures.addAll(<FakeResponse>[
FakeResponse.fail(400, 'MAIL_004'),
FakeResponse.fail(400, 'MAIL_004'),
]);
await tapTooltip(tester, MailComposeTexts.send);
expect(uploadedNames(), <String>['추가.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, <PickedFile>[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, <PickedFile>[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'], <Object?>[
<String, Object?>{'id': 'up-2', 'name': '추가.pdf'},
]);
});
testWidgets('떠난 뒤 기다린 저장이 고른 파일을 찾지 못하면(MAIL_004) 그 파일 없이 보내지 않고 알린다', (
WidgetTester tester,
) async {
await start(tester);
await open(tester, MailRoutes.compose);
await attach(tester, <PickedFile>[picked('추가.pdf')]);
await addRecipient(tester, 'doyun@x.kr');
final Completer<void> gate = Completer<void>();
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, <String>['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<void> gate = holdUpload();
await attach(tester, <PickedFile>[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<void>();
unawaited(h.router.push<void>(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<void>();
unawaited(h.router.push<void>(MailRoutes.composeDraft('d2')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(textOf(tester, subjectField), '쓰던 메일');
});
testWidgets(
'기기가 답하지 않는 고르기는 시한(10분) 뒤 풀린다 — 알리고 [보내기] · [파일 첨부]가 다시 되고 다음 고르기가 묶이지 않는다',
(WidgetTester tester) async {
// 기기 채널 쪽 차례 · 시한까지 보려고 실제 보관소를 쓴다 — 첫 고르기는 답이 오지 않는다(고른 앱이 멈춤)
final List<MethodCall> channelCalls = <MethodCall>[];
final Completer<Object?> stuck = Completer<Object?>();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(PlatformFileOpener.channel, (
MethodCall call,
) {
channelCalls.add(call);
return channelCalls.length == 1
? stuck.future
: Future<Object?>.value(<String, Object?>{
'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<IconButton>(attachButton).onPressed, isNull);
await tester.pump(kPickTimeout);
await tester.pumpAndSettle();
expect(pickingBar, findsNothing);
expect(snack(MailComposeTexts.pickReadFailed), findsOneWidget);
expect(tester.widget<IconButton>(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<void> gate = Completer<void>();
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<IconButton>(attachButton).onPressed, isNull);
gate.complete();
await tester.pumpAndSettle();
expect(pickingBar, findsNothing);
expect(tester.widget<IconButton>(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);
});
});
}
+80 -1
View File
@@ -11,7 +11,7 @@ import 'mail_fakes.dart';
typedef SentRequest = ({Map<String, Object?> body, String? key});
/// 쓰기 화면의 API 를 흉내 내는 가짜 뒤쪽 — 임시 저장(POST · PUT · GET) · 발송 · 서명 · 서명 그림 · 보내기 설정 ·
/// 받는 사람 추천 · 주소록 그룹 · 구성원 · 대용량 파일 상태.
/// 받는 사람 추천 · 주소록 그룹 · 구성원 · 대용량 파일 상태 · 첨부 올리기 · 첨부 한도.
///
/// 저장본은 메모리에 둔다 — PUT 은 새 id 로 바꾸고 옛것을 지운다(계약 2절). 실패([draftFailure] · [sendFailure])와
/// 문([saveGate] · [sendGate])으로 응답을 늦추거나 실패시킨다.
@@ -74,8 +74,28 @@ class FakeComposeBackend {
/// 있으면 대용량 파일 상태 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다)
Completer<void>? largeFileGate;
/// 받은 첨부 올리기 차례대로 — multipart 의 이름 · 형식 · 길이
final List<({String name, String type, int length})> uploads =
<({String name, String type, int length})>[];
/// 다음 올리기들이 이 응답으로 실패한다(차례대로 하나씩 쓴다)
final List<FakeResponse> uploadFailures = <FakeResponse>[];
/// 올리기 응답이 차례대로 이것들이 열릴 때까지 기다린다(온 차례대로 하나씩 쓴다 — 없으면 바로 답한다)
final List<Completer<void>> uploadGates = <Completer<void>>[];
/// 첨부 한도 응답(`MailQuota`) — null 이면 받지 못한다(503 `MAIL_001`)
Map<String, Object?>? quota = quotaJson();
/// 있으면 올리기 응답의 크기를 이것으로 준다(서버가 센 크기 — 없으면 보낸 길이)
int? uploadedSize;
/// 있으면 첨부 한도 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다)
Completer<void>? 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': <String, Object?>{'hasNext': false, 'nextCursor': null},
});
});
server.on('POST', ComposeApiPaths.mailAttachments, _upload);
server.on('GET', ComposeApiPaths.mailQuota, (_) async {
final Completer<void>? gate = quotaGate;
if (gate != null) {
quotaGate = null;
await gate.future;
}
final Map<String, Object?>? current = quota;
return current == null
? FakeResponse.fail(503, 'MAIL_001')
: FakeResponse.ok(current);
});
server.onPattern('GET', _filePath, (RequestOptions options) async {
final Completer<void>? gate = largeFileGate;
if (gate != null) {
@@ -246,6 +278,27 @@ class FakeComposeBackend {
});
}
Future<FakeResponse> _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, <String, Object?>{
'success': true,
'data': attachmentJson(
'up-${++_nextUpload}',
name: serverFileName(name),
type: file.contentType.toString(),
size: uploadedSize ?? file.length,
),
});
}
Future<FakeResponse> _send(RequestOptions options) async {
final Map<String, Object?> body = options.data! as Map<String, Object?>;
sends.add((
@@ -296,3 +349,29 @@ class FakeComposeBackend {
});
}
}
/// 첨부 한도 응답(`MailQuota`) — 기본은 서버 기본값(20 · 25MB)
Map<String, Object?> quotaJson({
int maxBytes = 20 * 1024 * 1024,
int totalMaxBytes = 25 * 1024 * 1024,
}) => <String, Object?>{
'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<int> runes = name.runes.toList();
if (runes.length <= maxLength) return name;
final int dot = name.lastIndexOf('.');
final List<int> extension = dot > 0
? name.substring(dot).runes.toList()
: <int>[];
return String.fromCharCodes(<int>[
...runes.take(maxLength - extension.length),
...extension,
]);
}
+13
View File
@@ -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<int> body})> requestBodies =
<({String path, List<int> 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<int> body = <int>[];
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')
+101
View File
@@ -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<PickResult> results = <PickResult>[];
/// 받은 고르기 — 넘겨받은 한도들(남은 개수 · 파일당 · 남은 합계 · 허용 확장자)
final List<
({int maxBytes, int maxCount, int maxTotalBytes, List<String> extensions})
>
picks =
<
({
int maxBytes,
int maxCount,
int maxTotalBytes,
List<String> extensions,
})
>[];
/// 있으면 고르기가 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) — 고르는 창이 열려 있는 동안
Completer<void>? pickGate;
/// 복사본 — 경로마다 바이트
final Map<String, List<int>> copies = <String, List<int>>{};
/// 지운 복사본 차례대로
final List<String> deleted = <String>[];
int purgeCount = 0;
int _next = 0;
/// 고른 파일 하나 — 복사본을 두고 그 [PickedFile] 을 준다
PickedFile copy(String name, List<int> 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<PickedFile> files) =>
results.add(PickResult(PickOutcome.picked, files));
@override
Future<PickResult> pick({
required int maxBytes,
required int maxCount,
required int maxTotalBytes,
required List<String> extensions,
}) async {
picks.add((
maxBytes: maxBytes,
maxCount: maxCount,
maxTotalBytes: maxTotalBytes,
extensions: extensions,
));
final Completer<void>? gate = pickGate;
if (gate != null) {
pickGate = null;
await gate.future;
}
return results.isEmpty
? const PickResult(PickOutcome.canceled)
: results.removeAt(0);
}
@override
Future<bool> exists(String path) async => copies.containsKey(path);
/// 조각 넷으로 흘린다 — 보내는 진행이 여러 번 온다
@override
Stream<List<int>> openRead(String path) {
final List<int>? bytes = copies[path];
if (bytes == null) {
return Stream<List<int>>.error(const FileSystemException('없음(시험)'));
}
final int step = (bytes.length / 4).ceil().clamp(1, bytes.length + 1);
return Stream<List<int>>.fromIterable(<List<int>>[
for (int start = 0; start < bytes.length; start += step)
bytes.sublist(start, (start + step).clamp(0, bytes.length)),
]);
}
@override
Future<void> delete(String path) async {
deleted.add(path);
copies.remove(path);
}
@override
Future<void> purge() async {
purgeCount++;
copies.clear();
}
}
+7 -1
View File
@@ -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<Override> overrides = <Override>[];
@@ -82,6 +87,7 @@ class MailAppHarness {
downloadedFileStoreProvider.overrideWithValue(files),
fileOpenerProvider.overrideWithValue(opener),
mailLinkLauncherProvider.overrideWithValue(links.call),
pickedFileStoreProvider.overrideWithValue(pickedStore),
...overrides,
],
child: const App(),