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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user