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:
2026-11-07 04:19:57 +09:00
parent f39f41ed4b
commit 3a126bdf83
27 changed files with 4360 additions and 136 deletions
@@ -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()
}
}