diff --git a/flutter/README.md b/flutter/README.md
index c4e1389..b0f55a4 100644
--- a/flutter/README.md
+++ b/flutter/README.md
@@ -56,13 +56,22 @@ flutter build appbundle --release # Play 에 올리는 꼴 — 아래 서명
- **Android 백업을 껐다**(`android:allowBackup="false"` · `res/xml/data_extraction_rules.xml`) — 보안 저장소의 값은 이 기기의 Keystore 키로 암호화되어 다른 기기로 복원되면 읽지 못하고, 첫 실행 표시까지 복원되면 남은 토큰 지우기가 돌지 않는다.
- **iOS Keychain 은 앱을 지워도 남는다** — 일반 저장소(`shared_preferences`)의 첫 실행 표시가 없으면 앱을 띄우기 전에 보안 저장소를 모두 지운다(`core/storage/token_store.dart` 의 `prepare`).
- 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각)로 남은 시간을 잰다.
+- **받은 메일 본문(WebView)**: 앱이 한 번 더 정화한 문서(`features/mail/domain/mail_html.dart` — 허용 목록 · CSP `default-src 'none'; img-src data:`)만 넣고,
+ 스크립트 · 파일 · content 주소 접근을 끄고, 주 틀의 탐색은 모두 막는다(링크는 바깥 브라우저 · mailto 는 쓰기 화면). 아래 틀(iframe)의 탐색은 안드로이드가 판정에 넘기지 않는다 —
+ 아래 틀은 정화(iframe 을 내용째 버림)와 CSP(`default-src 'none'` — frame-src)가 막는다. 외부 이미지는 앱이 Dio(Bearer)로 받아 `data:` 로 넣는다 — 토큰이 WebView 에 가지 않는다.
+ 문서에 싣는 그림 글은 같은 그림이 여러 번 나오면 나온 만큼 세어 10MB 에서 멈춘다.
+- **받은 첨부**: 앱 임시 폴더(`cacheDir`)의 `downloads/` 에 받고, 다른 앱으로 열 때는 FileProvider(`${applicationId}.downloads` — 그 폴더만)의 주소와 읽기 권한 1회로 `ACTION_VIEW` 한다(`MainActivity.kt` 의 파일 채널).
+ 앱을 띄울 때(지난 실행이 남긴 것) · 메일을 보는 직원이 바뀔 때 모두 지운다. 파일 이름은 경로 · 제어 · 방향 제어 글자를 지우고 줄인다(`core/files/file_names.dart`).
+ 열 형식은 정리한 이름의 확장자에서 먼저 정하고(보낸 쪽 형식은 확장자를 모를 때만), 웹 문서 · SVG · 설치 파일 · 인증서 · 키 묶음은 받지 않고 알린다(`core/files/file_types.dart`).
+ 고르는 창(`createChooser`)으로 열지 않는다 — 직원이 고른 브라우저가 스크립트를 돌린다.
## 나중에 넣을 때 주의
- **build_runner(freezed · json_serializable · riverpod_generator)**: 같은 폴더의 `lib/core/api/error_lexicon.g.dart`(백엔드 레지스트리에서 만든 사전 — 직접 고치지 않는다)를 build_runner 가 자기 출력으로 보고 지울 수 있다.
넣을 때는 `build.yaml` 의 `generate_for` 를 모델 폴더로 좁히고, 처음 한 번 돌린 뒤 사전이 그대로인지(`git diff`) 확인한다.
-- **첨부 받기(스트림 · 바이트 응답)**: `responseType` 이 `bytes` · `stream` 이면 실패 응답의 본문도 JSON 으로 풀리지 않아 `toApiException` 이 HTTP 상태로만 코드를 정한다(`FILE_007` · `FILE_008` 같은 코드를 잃는다).
- 받기를 만들 때 실패 본문(작다)을 끝까지 읽어 UTF-8 · JSON 으로 풀어 표준 실패의 `error.code` 를 꺼낸 뒤 코드를 정한다. 성공 본문은 지금처럼 풀지 않고 그대로 넘긴다(시험이 지킨다).
+- **첨부 받기(스트림 · 바이트 응답)**: 실패 응답의 본문은 `ResponseInterceptor.onError` 가 64KB 까지 읽어 UTF-8 · JSON 으로 풀고 표준 실패의 `error.code` 를 꺼낸다
+ (`decodeRawErrorBody` — 그 전에는 HTTP 상태로만 정해 `FILE_007` · `FILE_008` 같은 코드를 잃었다). 성공 본문은 풀지 않고 그대로 넘긴다(시험이 지킨다).
+ 받는 도중 끊기거나 조각 사이 받기 한도(`receiveTimeout` — Dio 가 스트림에 싣는다)를 넘기면 응답이 이미 인터셉터를 지나 아무도 알리지 않으므로 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`).
## 라이선스
diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml
index 0773065..1030971 100644
--- a/flutter/android/app/src/main/AndroidManifest.xml
+++ b/flutter/android/app/src/main/AndroidManifest.xml
@@ -35,6 +35,19 @@
+
+
+
+
+ when (call.method) {
+ OPEN_METHOD -> result.success(
+ openFile(call.argument("path"), call.argument("type")),
+ )
+ else -> result.notImplemented()
+ }
+ }
+ }
+
+ private fun openFile(path: String?, type: String?): String {
+ if (path == null) return RESULT_FAILED
+ val file: File
+ val root: File
+ try {
+ root = File(cacheDir, DOWNLOADS_DIR).canonicalFile
+ file = File(path).canonicalFile
+ } catch (error: IOException) {
+ return RESULT_FAILED
+ }
+ // 받은 파일 폴더 밖은 열지 않는다 (../ · 심볼릭 링크로 앱의 다른 파일을 건네지 않게)
+ if (!file.path.startsWith(root.path + File.separator)) return RESULT_FAILED
+ if (!file.isFile) return RESULT_NOT_FOUND
+ val uri = try {
+ FileProvider.getUriForFile(this, packageName + AUTHORITY_SUFFIX, file)
+ } catch (error: IllegalArgumentException) {
+ return RESULT_FAILED
+ }
+ // 고르는 창(createChooser)은 쓰지 않는다 — 직원이 고른 브라우저가 스크립트를 돌린다
+ val intent = Intent(Intent.ACTION_VIEW)
+ .setDataAndType(uri, mimeTypeOf(type))
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ return try {
+ startActivity(intent)
+ RESULT_OPENED
+ } catch (error: ActivityNotFoundException) {
+ RESULT_NO_APP
+ } catch (error: SecurityException) {
+ RESULT_FAILED
+ }
+ }
+
+ /**
+ * 넘겨받은 형식 — Dart 가 정한다(`core/files/file_types.dart` 의 `fileOpenTypeOf` — 확장자에서 먼저, 열지 않는 형식은 받기 전에 막는다).
+ * 여기서는 모양만 다시 본다 — 틀리면 아무 형식(octet-stream)
+ */
+ private fun mimeTypeOf(type: String?): String {
+ val given = type?.trim()?.lowercase()
+ return if (given != null && MIME_PATTERN.matches(given)) given else GENERIC_TYPE
+ }
+
+ private companion object {
+ /** 짝: Dart `PlatformFileOpener.channel` · `openMethod` */
+ const val FILES_CHANNEL = "kr.it.arasystem.app/files"
+ const val OPEN_METHOD = "open"
+
+ /** 짝: Dart `kDownloadedFilesFolder` · `res/xml/downloaded_file_paths.xml` 의 cache-path */
+ const val DOWNLOADS_DIR = "downloads"
+
+ /** 짝: `AndroidManifest.xml` 의 provider authorities(`${applicationId}.downloads`) */
+ const val AUTHORITY_SUFFIX = ".downloads"
+
+ /** 짝: Dart `kGenericFileType` · `_typePattern`(`core/files/file_types.dart`) */
+ const val GENERIC_TYPE = "application/octet-stream"
+ val MIME_PATTERN = Regex("^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$")
+
+ const val RESULT_OPENED = "opened"
+ const val RESULT_NO_APP = "noApp"
+ const val RESULT_NOT_FOUND = "notFound"
+ const val RESULT_FAILED = "failed"
+ }
+}
diff --git a/flutter/android/app/src/main/res/xml/downloaded_file_paths.xml b/flutter/android/app/src/main/res/xml/downloaded_file_paths.xml
new file mode 100644
index 0000000..437824e
--- /dev/null
+++ b/flutter/android/app/src/main/res/xml/downloaded_file_paths.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/flutter/lib/core/api/response_interceptor.dart b/flutter/lib/core/api/response_interceptor.dart
index 114dcaf..2aafc45 100644
--- a/flutter/lib/core/api/response_interceptor.dart
+++ b/flutter/lib/core/api/response_interceptor.dart
@@ -1,3 +1,6 @@
+import 'dart:convert';
+import 'dart:typed_data';
+
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:dio/dio.dart';
@@ -41,12 +44,75 @@ class ResponseInterceptor extends Interceptor {
);
}
+ /// 실패 — 코드를 정한다. 바이트 · 스트림 응답(첨부 받기 · 그림)의 실패 본문은 JSON 으로 풀리지 않은 채 오므로
+ /// 먼저 읽어(작다 — [kMaxErrorBodyBytes] 까지) 표준 실패의 `error.code` 를 꺼낸다. 성공 본문은 건드리지 않는다.
+ ///
+ /// 무슨 일이 있어도 `handler.next` 로 끝낸다(인증 인터셉터와 같은 규칙) — 비동기 처리기가 던지면 요청이 끝나지 않고 매달린다
@override
- void onError(DioException err, ErrorInterceptorHandler handler) {
+ Future onError(
+ DioException err,
+ ErrorInterceptorHandler handler,
+ ) async {
if (err.error is ApiException) {
handler.next(err);
return;
}
- handler.next(err.copyWith(error: toApiException(err)));
+ DioException failure;
+ try {
+ final Response