generated from ttipo/project-base
feat(flutter): 메일 쓰기 화면 2차 — 첨부 고르기 · 올리기를 더한다
[파일 첨부]가 안드로이드 고르는 창(권한 없음)을 연다. 고른 파일은 앱 임시 폴더에 복사해 두고 그 복사본을 multipart 로 둘씩 올린다 — 올리기에 실패하거나 서버가 첨부를 찾지 못해도(MAIL_004) 다시 고르지 않고 다시 올리려고. - 사전 검사 · 보내기 막기는 웹과 같은 차례, 한도는 GET /api/mail-quota - 기기가 같은 차례로 미리 걸러 넣을 파일만 복사한다(content:// 만, 판정은 Dart 가 다시) - 다 올리면 서버가 정리한 이름 · 크기로 싣는다(이름 255자 — 웹과 같다) - MAIL_004 뒤 다시 올릴 수 없는 파일은 빼기 전까지 저장 · 보내기를 막는다 - 가져오는 · 올리는 동안 [보내기]를 막고(가져오기는 10분 시한), 올리는 중 나가기는 따로 묻는다 - 화면이 끝나거나 직원이 바뀌거나 앱을 띄울 때 복사본을 지운다 리뷰(M1~M4 · L 다섯 · 다시 본 리뷰의 L 하나)를 고쳤다. 시험 690 → 750. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
// 다음 정리 · 기기가 지운다
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user