generated from ttipo/project-base
feat(flutter): 메일 읽기 화면을 만든다 (App1Reader)
머리는 앱 화면, 본문은 스크립트를 끈 WebView 에 앱이 감싼 CSP 문서로 보인다. 받은 HTML 은 서버 정화 뒤 앱이 한 번 더 허용 목록으로 다시 쓰고, 외부 이미지는 [이미지 보기] 뒤 앱이 Dio 로 받아 data: 로 넣는다(토큰이 WebView 에 가지 않는다). - 읽음 · 중요 · 이동 · 스팸 · 삭제 · 영구 삭제, 붙잡힌 메일의 보내기 취소 · 예약 취소는 목록과 같은 규칙. 응답은 그 요청이 바꾼 칸만 지금 값에 덮는다 - 첨부는 앱 임시 폴더(cacheDir/downloads)에 받아 FileProvider + ACTION_VIEW 로 연다(파일 여는 플러그인 없이). 열 형식은 확장자 먼저, 웹 문서 · svg · 설치 파일 · 인증서는 받지 않는다 - 응답 인터셉터가 바이트 · 스트림 실패 본문(64KB 까지)에서 코드를 읽고, 뜻밖의 실패에도 handler.next 로 끝낸다 - 리뷰 H1: 같은 그림을 수백 번 쓴 메일이 문서를 GB 단위로 키우던 것 — 문서에 싣는 그림 글을 나온 횟수까지 세어 10M 자에서 멈춘다 - 리뷰 M1: 짧고 깊은 본문을 화면 isolate 에서 정화하던 것 — 길이와 중첩 비용으로 고르고(6,144자 · 비용 5,000), 그 밖은 Isolate.run - 새 의존성: webview_flutter 4.14.1 · webview_flutter_android 4.12.0 · html 0.15.7(+ csslib 1.0.2) — 배포 2주 넘음. path_provider · mime 은 lock 에 있던 판을 직접 의존으로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+11
-2
@@ -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`).
|
||||
|
||||
## 라이선스
|
||||
|
||||
|
||||
@@ -35,6 +35,19 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!--
|
||||
받은 첨부를 다른 앱으로 열 때 건네는 주소(content://) — 앱 임시 폴더의 downloads/ 안 파일만(res/xml/downloaded_file_paths.xml).
|
||||
밖에 열지 않고(exported=false), 연 앱에 그 파일 하나의 읽기 권한만 준다(MainActivity.kt 의 파일 채널)
|
||||
-->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.downloads"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/downloaded_file_paths" />
|
||||
</provider>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
||||
@@ -1,5 +1,94 @@
|
||||
package kr.it.arasystem.app
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
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.IOException
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
/**
|
||||
* 앱 화면 하나 — Flutter 가 그린다.
|
||||
*
|
||||
* 파일 채널(`kr.it.arasystem.app/files`): 앱이 받은 파일(메일 첨부)을 다른 앱으로 연다 — Dart `core/files/downloaded_files.dart` 의
|
||||
* `PlatformFileOpener` 가 부른다. 앱 임시 폴더의 [DOWNLOADS_DIR] 안 파일만, FileProvider 주소와 읽기 권한 1회로 `ACTION_VIEW` 한다.
|
||||
* 결과는 글 하나 — opened · noApp · notFound · failed. 경로 · 주소는 로그에 남기지 않는다.
|
||||
*/
|
||||
class MainActivity : FlutterActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FILES_CHANNEL)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
OPEN_METHOD -> result.success(
|
||||
openFile(call.argument<String>("path"), call.argument<String>("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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
앱이 다른 앱에 건네는 파일의 자리 — 앱 임시 폴더(cacheDir)의 downloads/ 한 곳뿐이다 (받은 메일 첨부).
|
||||
짝: Dart core/files/downloaded_files.dart 의 kDownloadedFilesFolder · MainActivity.kt 의 DOWNLOADS_DIR
|
||||
-->
|
||||
<paths>
|
||||
<cache-path
|
||||
name="downloads"
|
||||
path="downloads/" />
|
||||
</paths>
|
||||
@@ -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<void> 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<Object?>? response = err.response;
|
||||
if (response != null) {
|
||||
final Map<String, Object?>? decoded = await decodeRawErrorBody(
|
||||
response.data,
|
||||
);
|
||||
if (decoded != null) response.data = decoded;
|
||||
}
|
||||
failure = err.copyWith(error: toApiException(err));
|
||||
} on Object {
|
||||
// 실패 본문을 읽다 뜻밖에 실패했다 — 코드는 최종 폴백으로 정하고 다음 인터셉터로 넘긴다
|
||||
failure = err.copyWith(
|
||||
error: ApiException.fromCode(
|
||||
kFallbackCode,
|
||||
statusCode: err.response?.statusCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
handler.next(failure);
|
||||
}
|
||||
}
|
||||
|
||||
/// 바이트 · 스트림 응답의 실패 본문을 읽는 최대 크기 — 표준 실패 본문은 1KB 안팎이다
|
||||
const int kMaxErrorBodyBytes = 64 * 1024;
|
||||
|
||||
/// 바이트(`List<int>`) · 스트림(`ResponseBody`) 실패 본문 → JSON 객체. 이미 풀린 본문 · 너무 큼 · JSON 이 아님은 null.
|
||||
/// 스트림은 끝까지(한도 안) 읽어 연결을 놓아 준다
|
||||
Future<Map<String, Object?>?> decodeRawErrorBody(Object? data) async {
|
||||
final List<int>? bytes;
|
||||
if (data is ResponseBody) {
|
||||
bytes = await _readLimited(data.stream);
|
||||
} else if (data is List<int>) {
|
||||
bytes = data.length <= kMaxErrorBodyBytes ? data : null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (bytes == null || bytes.isEmpty) return null;
|
||||
try {
|
||||
final Object? json = jsonDecode(utf8.decode(bytes, allowMalformed: true));
|
||||
return json is Map<String, Object?> ? json : null;
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<int>?> _readLimited(Stream<List<int>> stream) async {
|
||||
final BytesBuilder builder = BytesBuilder(copy: false);
|
||||
try {
|
||||
await for (final List<int> chunk in stream) {
|
||||
builder.add(chunk);
|
||||
// 한도를 넘으면 그만 읽는다(구독을 끊어 연결을 놓는다) — 코드는 상태로 정한다
|
||||
if (builder.length > kMaxErrorBodyBytes) return null;
|
||||
}
|
||||
} on Object {
|
||||
return null;
|
||||
}
|
||||
return builder.takeBytes();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:arasystem_app/core/files/file_names.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// 받은 파일을 두는 폴더 이름 — 앱 임시 폴더(Android `cacheDir`) 아래.
|
||||
/// **짝: `android/app/src/main/res/xml/downloaded_file_paths.xml` 의 `cache-path` 와 `MainActivity.kt` 의 `DOWNLOADS_DIR`** — 바꾸면 셋 다
|
||||
const String kDownloadedFilesFolder = 'downloads';
|
||||
|
||||
/// 받은 파일 하나의 자리 — 받는 동안 조각을 이어 쓰고, 다 받으면 닫는다
|
||||
abstract class DownloadTarget {
|
||||
/// 기기 안의 경로 — 다른 앱으로 열 때 쓴다(로그 · 알림에 싣지 않는다)
|
||||
String get path;
|
||||
|
||||
/// 받은 조각을 이어 쓴다
|
||||
void add(List<int> chunk);
|
||||
|
||||
/// 다 받았다 — 쓴 것을 마저 내보내고 닫는다
|
||||
Future<void> close();
|
||||
|
||||
/// 받다 말았다(실패 · 취소) — 닫고 지운다
|
||||
Future<void> discard();
|
||||
}
|
||||
|
||||
/// 받은 파일을 잠깐 두는 곳 — 앱이 다른 앱에 파일을 건네려고 둔다(모듈 공통).
|
||||
///
|
||||
/// 정리하는 때: 앱을 띄울 때(main — 지난 실행이 남긴 것) · 메일을 보는 직원이 바뀔 때(로그아웃 · 세션 끝 · 다른 직원) ·
|
||||
/// 기기가 저장 공간이 모자랄 때(안드로이드가 임시 폴더를 비운다). 다른 앱이 아직 읽는 중일 수 있어 연 직후에는 지우지 않는다.
|
||||
abstract class DownloadedFileStore {
|
||||
/// 새 파일 자리 — 받을 때마다 새 폴더 하나(같은 이름이어도 덮지 않는다). 이름은 [safeFileName] 으로 정리한다
|
||||
Future<DownloadTarget> create(String fileName);
|
||||
|
||||
/// 받아 둔 파일이 아직 있는가 (기기가 임시 폴더를 비웠을 수 있다)
|
||||
Future<bool> exists(String path);
|
||||
|
||||
/// 모두 지운다
|
||||
Future<void> purge();
|
||||
}
|
||||
|
||||
/// 받은 파일을 두는 곳 — 앱 임시 폴더의 [kDownloadedFilesFolder] 아래
|
||||
final Provider<DownloadedFileStore> downloadedFileStoreProvider =
|
||||
Provider<DownloadedFileStore>((Ref ref) => CacheDirFileStore());
|
||||
|
||||
/// 앱 임시 폴더(path_provider `getTemporaryDirectory` — Android `cacheDir`)를 쓰는 [DownloadedFileStore].
|
||||
///
|
||||
/// 지우기와 새 자리 만들기는 부른 차례대로 하나씩 한다 — 앱을 띄울 때의 정리가 막 만든 자리를 지우지 않게.
|
||||
class CacheDirFileStore implements DownloadedFileStore {
|
||||
CacheDirFileStore({
|
||||
Future<Directory> Function() baseDirectory = getTemporaryDirectory,
|
||||
Random? random,
|
||||
}) : _baseDirectory = baseDirectory,
|
||||
_random = random ?? Random.secure();
|
||||
|
||||
final Future<Directory> Function() _baseDirectory;
|
||||
final Random _random;
|
||||
Future<void> _queue = Future<void>.value();
|
||||
|
||||
/// 받을 때마다 만드는 폴더 이름의 길이(16진수)
|
||||
static const int _folderNameLength = 16;
|
||||
|
||||
Future<T> _serial<T>(Future<T> Function() task) {
|
||||
final Future<T> next = _queue.then((_) => task());
|
||||
_queue = next.then<void>((_) {}, onError: (Object _) {});
|
||||
return next;
|
||||
}
|
||||
|
||||
/// 받은 파일 폴더 — 임시 폴더를 알 수 없으면(플러그인 · 기기 사정) 저장하지 못한 것으로 다룬다([FileSystemException])
|
||||
Future<Directory> _root() async {
|
||||
final Directory base;
|
||||
try {
|
||||
base = await _baseDirectory();
|
||||
} on FileSystemException {
|
||||
rethrow;
|
||||
} on Exception {
|
||||
throw const FileSystemException('임시 폴더를 찾지 못했습니다');
|
||||
}
|
||||
return Directory('${base.path}/$kDownloadedFilesFolder');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DownloadTarget> create(String fileName) => _serial(() async {
|
||||
final Directory root = await _root();
|
||||
final Directory folder = Directory('${root.path}/${_folderName()}');
|
||||
await folder.create(recursive: true);
|
||||
final File file = File('${folder.path}/${safeFileName(fileName)}');
|
||||
return _FileTarget(file, file.openWrite());
|
||||
});
|
||||
|
||||
@override
|
||||
Future<bool> exists(String path) => File(path).exists();
|
||||
|
||||
@override
|
||||
Future<void> purge() => _serial(() async {
|
||||
try {
|
||||
final Directory root = await _root();
|
||||
if (await root.exists()) await root.delete(recursive: true);
|
||||
} on FileSystemException {
|
||||
// 임시 폴더를 모르거나 다른 앱이 열고 있는 등 — 다음 정리 · 기기가 지운다
|
||||
}
|
||||
});
|
||||
|
||||
String _folderName() => List<String>.generate(
|
||||
_folderNameLength,
|
||||
(_) => _random.nextInt(16).toRadixString(16),
|
||||
).join();
|
||||
}
|
||||
|
||||
class _FileTarget implements DownloadTarget {
|
||||
_FileTarget(this._file, this._sink) {
|
||||
// 쓰기가 실패하면(폴더가 지워짐 · 저장 공간) 그 실패는 close · discard 가 받는다 — 그 전에 잡히지 않은 오류로 알려지지 않게
|
||||
unawaited(_sink.done.then<void>((_) {}, onError: (Object _) {}));
|
||||
}
|
||||
|
||||
final File _file;
|
||||
final IOSink _sink;
|
||||
bool _closed = false;
|
||||
|
||||
@override
|
||||
String get path => _file.path;
|
||||
|
||||
@override
|
||||
void add(List<int> chunk) => _sink.add(chunk);
|
||||
|
||||
/// 닫기가 남은 쓰기를 마저 내보낸다 — `flush` 는 부르지 않는다(쓰기가 이미 실패한 IOSink 의 flush 는 끝나지 않는다.
|
||||
/// close 는 그 실패를 [FileSystemException] 으로 돌려준다)
|
||||
@override
|
||||
Future<void> close() async {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
await _sink.close();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> discard() async {
|
||||
if (!_closed) {
|
||||
_closed = true;
|
||||
try {
|
||||
await _sink.close();
|
||||
} on FileSystemException {
|
||||
// 지우는 것이 목적이다
|
||||
}
|
||||
}
|
||||
try {
|
||||
await _file.parent.delete(recursive: true);
|
||||
} on FileSystemException {
|
||||
// 이미 없거나 다른 앱이 열었다 — 다음 정리 · 기기가 지운다
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 받은 파일을 다른 앱으로 연 결과
|
||||
enum FileOpenResult {
|
||||
/// 다른 앱을 띄웠다(또는 고르는 창)
|
||||
opened,
|
||||
|
||||
/// 이 형식을 열 앱이 없다
|
||||
noApp,
|
||||
|
||||
/// 파일이 없다(기기가 임시 폴더를 비웠다)
|
||||
notFound,
|
||||
|
||||
/// 그 밖의 실패(이 기기에서 열 수 없음 — iOS 는 아직 없다)
|
||||
failed,
|
||||
}
|
||||
|
||||
/// 받은 파일을 다른 앱으로 연다 — 시험은 덮어쓴다
|
||||
abstract class FileOpener {
|
||||
Future<FileOpenResult> open({required String path, required String mimeType});
|
||||
}
|
||||
|
||||
final Provider<FileOpener> fileOpenerProvider = Provider<FileOpener>(
|
||||
(Ref ref) => const PlatformFileOpener(),
|
||||
);
|
||||
|
||||
/// 안드로이드 `ACTION_VIEW`(`MainActivity.kt` 의 파일 채널) — 앱의 FileProvider 가 [kDownloadedFilesFolder] 안의 파일만 건넨다.
|
||||
///
|
||||
/// 패키지를 더하지 않고 앱 안에 둔 까닭: 파일 여는 플러그인은 앱에 저장소 · 사진 권한(Play 의 사진 · 동영상 권한 정책)과
|
||||
/// 기기 전체를 가리키는 FileProvider 경로를 더하고, 공유(share) 플러그인은 "열기"가 아니라 "보내기" 창이다.
|
||||
/// 이 채널은 받은 파일 폴더 한 곳만 읽게 건넨다(읽기 권한 1회 — `FLAG_GRANT_READ_URI_PERMISSION`). iOS 는 붙일 때 만든다
|
||||
class PlatformFileOpener implements FileOpener {
|
||||
const PlatformFileOpener();
|
||||
|
||||
/// 짝: `MainActivity.kt` 의 `FILES_CHANNEL` · `OPEN_METHOD`
|
||||
static const MethodChannel channel = MethodChannel(
|
||||
'kr.it.arasystem.app/files',
|
||||
);
|
||||
static const String openMethod = 'open';
|
||||
|
||||
@override
|
||||
Future<FileOpenResult> open({
|
||||
required String path,
|
||||
required String mimeType,
|
||||
}) async {
|
||||
try {
|
||||
final String? result = await channel.invokeMethod<String>(
|
||||
openMethod,
|
||||
<String, Object?>{'path': path, 'type': mimeType},
|
||||
);
|
||||
return switch (result) {
|
||||
'opened' => FileOpenResult.opened,
|
||||
'noApp' => FileOpenResult.noApp,
|
||||
'notFound' => FileOpenResult.notFound,
|
||||
_ => FileOpenResult.failed,
|
||||
};
|
||||
} on PlatformException {
|
||||
return FileOpenResult.failed;
|
||||
} on MissingPluginException {
|
||||
return FileOpenResult.failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/// 받은 파일을 기기에 둘 때의 이름 — 순수 함수 (모듈 공통: 메일 첨부 · 나중의 파일 모듈).
|
||||
///
|
||||
/// 이름은 보낸 쪽이 정한 글이라 믿지 않는다 — 경로 문자 · 제어 문자 · 방향 제어 문자를 지우고 길이를 줄인다
|
||||
/// (서버의 첨부 이름 정리 `attachment.policy.ts` 와 같은 결 — 확장자는 남긴다). 저장 경로는 앱이 만든 폴더 안 한 칸뿐이다.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
/// 이름이 비면 쓰는 이름
|
||||
const String kFallbackFileName = '첨부파일';
|
||||
|
||||
/// 저장 이름의 최대 UTF-8 바이트 — 안드로이드 파일 시스템(ext4 · f2fs)의 한 칸 한도(255)보다 넉넉히 작게
|
||||
const int kMaxFileNameBytes = 200;
|
||||
|
||||
/// 남기는 확장자의 최대 길이 (점 뒤)
|
||||
const int _maxExtensionLength = 16;
|
||||
|
||||
/// 지우는 글자 — 제어 문자(C0 · DEL · C1)
|
||||
bool _isControl(int rune) => rune <= 0x1F || (rune >= 0x7F && rune <= 0x9F);
|
||||
|
||||
/// 지우는 글자 — 방향 제어 · 보이지 않는 글자(오른쪽에서 왼쪽 표시로 "…fdp.exe" 를 "…exe.pdf" 처럼 보이게 하는 속임 이름)
|
||||
bool _isInvisibleFormat(int rune) =>
|
||||
rune == 0x061C ||
|
||||
(rune >= 0x200B && rune <= 0x200F) ||
|
||||
(rune >= 0x202A && rune <= 0x202E) ||
|
||||
(rune >= 0x2060 && rune <= 0x2069) ||
|
||||
rune == 0xFEFF;
|
||||
|
||||
/// 밑줄로 바꾸는 글자 — 경로 구분 · 다른 시스템이 이름에 받지 않는 글자
|
||||
const String _replacedCharacters = r'/\:*?"<>|';
|
||||
|
||||
/// 기기에 둘 파일 이름 — 비거나 `.` · `..` 뿐이면 [fallback]
|
||||
String safeFileName(String name, {String fallback = kFallbackFileName}) {
|
||||
final StringBuffer cleaned = StringBuffer();
|
||||
for (final int rune in name.runes) {
|
||||
if (_isControl(rune) || _isInvisibleFormat(rune)) continue;
|
||||
final String char = String.fromCharCode(rune);
|
||||
cleaned.write(_replacedCharacters.contains(char) ? '_' : char);
|
||||
}
|
||||
// 앞뒤 공백 · 점을 지운다 — 앞의 점은 숨은 파일, 끝의 점 · 공백은 다른 시스템이 받지 않는다
|
||||
String result = cleaned.toString().trim();
|
||||
result = result.replaceAll(RegExp(r'^[.\s]+|[.\s]+$'), '');
|
||||
if (result.isEmpty) return fallback;
|
||||
return _fitBytes(result, kMaxFileNameBytes);
|
||||
}
|
||||
|
||||
/// UTF-8 [maxBytes] 안으로 줄인다 — 확장자(점 뒤 짧은 영문 · 숫자)는 남기고 앞부분을 글자 단위로 자른다
|
||||
String _fitBytes(String name, int maxBytes) {
|
||||
if (utf8.encode(name).length <= maxBytes) return name;
|
||||
final int dot = name.lastIndexOf('.');
|
||||
final String extension =
|
||||
dot > 0 &&
|
||||
name.length - dot - 1 <= _maxExtensionLength &&
|
||||
RegExp(r'^[A-Za-z0-9]+$').hasMatch(name.substring(dot + 1))
|
||||
? name.substring(dot)
|
||||
: '';
|
||||
final String base = extension.isEmpty ? name : name.substring(0, dot);
|
||||
final int budget = maxBytes - utf8.encode(extension).length;
|
||||
final StringBuffer kept = StringBuffer();
|
||||
int used = 0;
|
||||
for (final int rune in base.runes) {
|
||||
final int size = utf8.encode(String.fromCharCode(rune)).length;
|
||||
if (used + size > budget) break;
|
||||
kept.writeCharCode(rune);
|
||||
used += size;
|
||||
}
|
||||
final String trimmed = kept.toString().trimRight();
|
||||
return trimmed.isEmpty
|
||||
? '$kFallbackFileName$extension'
|
||||
: '$trimmed$extension';
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/// 받은 파일을 다른 앱으로 열 때의 형식(MIME)과 앱에서 열지 않는 형식 — 순수 함수 (모듈 공통: 메일 첨부 · 나중의 파일 모듈).
|
||||
///
|
||||
/// 형식은 보낸 쪽이 정한 글이라 확장자보다 믿지 않는다 — 열 형식은 정리한 이름([safeFileName])의 확장자에서 먼저 정하고
|
||||
/// (`package:mime` 의 표), 확장자를 모를 때만 서버가 알려 준 형식(첨부의 `type`)을 쓴다. 안드로이드는 이 형식으로 열 앱을 고른다.
|
||||
///
|
||||
/// 웹 문서 · SVG · 설치 파일 · 인증서 · 키 묶음은 앱에서 열지 않는다 — 다른 앱(브라우저)이 그 파일의 스크립트를 돌리거나
|
||||
/// 기기에 설치 · 등록하는 창을 띄우지 않게. 확장자 · 확장자에서 나온 형식 · 서버 형식 가운데 하나라도 걸리면 받지도 않는다.
|
||||
/// 고르는 창(`Intent.createChooser`)으로 여는 길도 쓰지 않는다 — 직원이 고른 브라우저가 스크립트를 돌린다
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/core/files/file_names.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
|
||||
/// 형식을 모를 때 — 안드로이드는 이 형식을 받는 앱이 드물다(열 앱이 없다고 알린다)
|
||||
const String kGenericFileType = 'application/octet-stream';
|
||||
|
||||
/// 앱에서 열지 않는 확장자 (소문자 — 정리한 이름의 마지막 점 뒤)
|
||||
const Set<String> kBlockedOpenExtensions = <String>{
|
||||
// 웹 문서 — 브라우저가 열면 스크립트가 돈다
|
||||
'html', 'htm', 'xhtml', 'xht', 'shtml', 'mht', 'mhtml', //
|
||||
// SVG — 그림이지만 스크립트를 담는다
|
||||
'svg', 'svgz', //
|
||||
// 설치 파일
|
||||
'apk', 'apks', 'xapk', //
|
||||
// 인증서 · 키 묶음 — 열면 기기에 설치하는 창이 뜬다
|
||||
'p12', 'pfx', 'cer', 'crt', 'der', 'pem', 'p7b', 'p7c',
|
||||
};
|
||||
|
||||
/// 앱에서 열지 않는 형식 — 위 확장자에서 나오는 형식(`package:mime` 의 표 — 시험이 빠짐없이 맞춰 본다)과
|
||||
/// 서버 · 보낸 쪽이 같은 갈래에 흔히 쓰는 형식
|
||||
const Set<String> kBlockedOpenTypes = <String>{
|
||||
// 웹 문서 · MHTML
|
||||
'text/html', 'application/xhtml+xml', 'multipart/related', //
|
||||
'application/x-mimearchive', //
|
||||
// SVG
|
||||
'image/svg+xml', //
|
||||
// 설치 파일
|
||||
'application/vnd.android.package-archive', //
|
||||
// 인증서 · 키 묶음
|
||||
'application/x-pkcs12', 'application/pkcs12', 'application/pkix-cert', //
|
||||
'application/x-x509-ca-cert', 'application/x-x509-user-cert', //
|
||||
'application/x-x509-server-cert', 'application/x-pem-file', //
|
||||
'application/x-pkcs7-certificates', 'application/pkcs7-mime', //
|
||||
'application/x-pkcs7-mime', 'application/x-pkcs7-certreqresp',
|
||||
};
|
||||
|
||||
/// 형식 글의 모양 — `종류/이름` (짝: `MainActivity.kt` 의 `MIME_PATTERN` — 앱도 넘겨받은 형식을 이 모양으로 다시 본다)
|
||||
final RegExp _typePattern = RegExp(
|
||||
r'^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$',
|
||||
);
|
||||
|
||||
/// 정리한 이름의 확장자 — 소문자, 없으면 빈 글
|
||||
String fileExtensionOf(String fileName) {
|
||||
final String name = safeFileName(fileName);
|
||||
final int dot = name.lastIndexOf('.');
|
||||
if (dot <= 0 || dot == name.length - 1) return '';
|
||||
return name.substring(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/// 서버가 알려 준 형식을 비교할 꼴로 — 소문자, `;` 뒤의 매개변수(`charset=` 등)를 뗀다. 모양이 틀리면 null
|
||||
String? normalizedFileType(String? type) {
|
||||
if (type == null) return null;
|
||||
final int semicolon = type.indexOf(';');
|
||||
final String value = (semicolon == -1 ? type : type.substring(0, semicolon))
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return _typePattern.hasMatch(value) ? value : null;
|
||||
}
|
||||
|
||||
/// 확장자에서 나오는 형식 — 모르거나 아무 형식(octet-stream)이면 null
|
||||
String? fileTypeOfExtension(String extension) {
|
||||
if (extension.isEmpty) return null;
|
||||
final String? type = lookupMimeType('file.$extension');
|
||||
return type == null || type == kGenericFileType ? null : type;
|
||||
}
|
||||
|
||||
/// 다른 앱으로 열 때 넘길 형식 — 확장자에서 먼저, 모르면 서버 형식(모양이 맞고 아무 형식이 아닐 때), 그것도 아니면 [kGenericFileType]
|
||||
String fileOpenTypeOf(String fileName, String? serverType) {
|
||||
final String? byExtension = fileTypeOfExtension(fileExtensionOf(fileName));
|
||||
if (byExtension != null) return byExtension;
|
||||
final String? given = normalizedFileType(serverType);
|
||||
return given ?? kGenericFileType;
|
||||
}
|
||||
|
||||
/// 앱에서 열지 않는 파일인가 — 확장자 · 확장자에서 나온 형식 · 서버 형식 가운데 하나라도 [kBlockedOpenExtensions] · [kBlockedOpenTypes] 에 걸리면
|
||||
bool isBlockedFileOpen(String fileName, String? serverType) {
|
||||
final String extension = fileExtensionOf(fileName);
|
||||
if (kBlockedOpenExtensions.contains(extension)) return true;
|
||||
final String? byExtension = fileTypeOfExtension(extension);
|
||||
if (byExtension != null && kBlockedOpenTypes.contains(byExtension)) {
|
||||
return true;
|
||||
}
|
||||
final String? given = normalizedFileType(serverType);
|
||||
return given != null && kBlockedOpenTypes.contains(given);
|
||||
}
|
||||
@@ -120,4 +120,26 @@ abstract final class AppSize {
|
||||
|
||||
/// [메일 쓰기] 단추 높이 (Material 넓은 단추 그대로) — 목록 아래 여백이 이만큼 + 40 이다
|
||||
static const double fabHeight = 56;
|
||||
|
||||
// ── 앱 보드에서 정한 크기 (`App1Reader` — 2026-11-05 확정) ──
|
||||
/// 읽기 화면의 위 막대 높이 — 뒤로 · 삭제 · 더 보기
|
||||
static const double readerBarHeight = 56;
|
||||
|
||||
/// 읽기 화면의 아래 행동 막대 높이 — 답장 · 전체 답장 · 전달
|
||||
static const double readerActionBarHeight = 64;
|
||||
|
||||
/// 첨부 줄의 최소 높이
|
||||
static const double attachmentRowMinHeight = 64;
|
||||
|
||||
/// 첨부 줄의 파일 아이콘 칸
|
||||
static const double fileIconBox = 40;
|
||||
|
||||
/// 첨부를 받는 동안의 진행 막대 두께
|
||||
static const double progressBarHeight = 4;
|
||||
|
||||
/// 머리 펼침의 칸 이름 열 폭 ("보낸 사람" · "받는 사람" · "숨은참조")
|
||||
static const double detailsLabelWidth = 64;
|
||||
|
||||
/// 더 보기 메뉴의 폭
|
||||
static const double readerMenuWidth = 224;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/// 파일 크기 표기 — 웹 `shared/utils/format.ts` 의 `formatFileSize` 와 같은 글(순수 함수).
|
||||
///
|
||||
/// "248 KB" · "1.2 MB" · "38 MB" — 1KB 미만은 바이트 그대로, 그 위는 KB · MB · GB · TB. KB 는 정수, MB 이상은 10 미만일 때 소수 한 자리.
|
||||
library;
|
||||
|
||||
const int _bytesPerUnit = 1024;
|
||||
const List<String> _units = <String>['KB', 'MB', 'GB', 'TB'];
|
||||
const int _decimalThreshold = 10;
|
||||
|
||||
/// 크기 표기 — 음수면 빈 글
|
||||
String formatFileSize(int bytes) {
|
||||
if (bytes < 0) return '';
|
||||
if (bytes < _bytesPerUnit) return '$bytes B';
|
||||
double value = bytes / _bytesPerUnit;
|
||||
int unit = 0;
|
||||
// 반올림 결과가 1024 가 되면("1024 KB") 다음 단위로 올린다
|
||||
while (value.round() >= _bytesPerUnit && unit < _units.length - 1) {
|
||||
value /= _bytesPerUnit;
|
||||
unit++;
|
||||
}
|
||||
final String shown = unit > 0 && value < _decimalThreshold
|
||||
? _oneDecimal(value)
|
||||
: value.round().toString();
|
||||
return '$shown ${_units[unit]}';
|
||||
}
|
||||
|
||||
/// 소수 한 자리 — 끝의 ".0" 은 뺀다(웹 `Math.round(value * 10) / 10` 이 내는 글과 같다)
|
||||
String _oneDecimal(double value) {
|
||||
final double rounded = (value * 10).round() / 10;
|
||||
return rounded == rounded.roundToDouble()
|
||||
? rounded.round().toString()
|
||||
: rounded.toStringAsFixed(1);
|
||||
}
|
||||
@@ -37,6 +37,18 @@ String koreanMonthDay(DateTime local) => '${local.month}월 ${local.day}일';
|
||||
String koreanNumericDate(DateTime local) =>
|
||||
'${local.year}. ${local.month}. ${local.day}.';
|
||||
|
||||
/// 요일 한 글자 — 월 · 화 · … · 일 (웹 `{ weekday: 'short' }`)
|
||||
String koreanWeekday(DateTime local) =>
|
||||
const <String>['월', '화', '수', '목', '금', '토', '일'][local.weekday - 1];
|
||||
|
||||
/// 달 · 날 · 요일 · 시각 — "11월 7일 (토) 오전 9:00" (웹 `formatScheduleTime` — 예약 시각. 7일 안이라 연도가 없다)
|
||||
String koreanScheduleTime(DateTime local) =>
|
||||
'${koreanMonthDay(local)} (${koreanWeekday(local)}) ${koreanTimeOfDay(local)}';
|
||||
|
||||
/// 연도까지 — "2026년 11월 4일 (수) 오후 4:20" (앱 보드 `App1Reader` 의 머리 펼침 "날짜")
|
||||
String koreanFullDateTime(DateTime local) =>
|
||||
'${local.year}년 ${koreanScheduleTime(local)}';
|
||||
|
||||
/// 같은 달력 날인가 (둘 다 같은 시간대로 바꾼 값)
|
||||
bool isSameCalendarDay(DateTime a, DateTime b) =>
|
||||
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// 본문의 http · https 링크를 바깥 앱(브라우저 · 그 주소를 받는 앱)으로 여는 함수 — 시험은 덮어쓴다
|
||||
typedef MailLinkLauncher = Future<bool> Function(Uri uri);
|
||||
|
||||
final Provider<MailLinkLauncher> mailLinkLauncherProvider =
|
||||
Provider<MailLinkLauncher>((Ref ref) => openMailLinkExternally);
|
||||
|
||||
/// 바깥 앱으로 연다 — 열었으면 true.
|
||||
///
|
||||
/// `canLaunchUrl` 은 쓰지 않는다(debug 판 안드로이드가 묻는 주소를 로그에 남긴다 — flutter 스킬). 못 열면 플러그인이 던지는
|
||||
/// 예외의 문장에 주소째 들어 있다 — 잡아서 버린다(메일 속 주소에는 추적 번호 · 개인 정보가 들어 있을 수 있다)
|
||||
Future<bool> openMailLinkExternally(Uri uri) async {
|
||||
try {
|
||||
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} on Object {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 메일 한 통의 첨부 — 계약 2절 `MessageAttachment`
|
||||
@immutable
|
||||
class MessageAttachment {
|
||||
const MessageAttachment({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.size,
|
||||
this.isInline = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
||||
/// 보낸 쪽이 붙인 이름 — 저장할 때는 다시 정리한다(`safeFileName`)
|
||||
final String name;
|
||||
|
||||
/// 서버가 알려 준 형식(`application/pdf` 등) — 다른 앱으로 열 때 쓴다
|
||||
final String type;
|
||||
|
||||
/// 바이트
|
||||
final int size;
|
||||
|
||||
/// 본문 안에 그림으로 들어갔다(cid → data:) — 첨부 목록에서 뺀다
|
||||
final bool isInline;
|
||||
|
||||
/// 모양이 틀리면 null — `isInline` 이 없으면(옛 서버) 거짓으로 본다
|
||||
static MessageAttachment? tryParse(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final Object? id = json['id'];
|
||||
final Object? name = json['name'];
|
||||
final Object? type = json['type'];
|
||||
final Object? size = json['size'];
|
||||
final Object? isInline = json['isInline'];
|
||||
if (id is! String ||
|
||||
id.isEmpty ||
|
||||
name is! String ||
|
||||
type is! String ||
|
||||
size is! num ||
|
||||
(isInline != null && isInline is! bool)) {
|
||||
return null;
|
||||
}
|
||||
return MessageAttachment(
|
||||
id: id,
|
||||
name: name,
|
||||
type: type,
|
||||
size: size.toInt(),
|
||||
isInline: isInline == true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is MessageAttachment &&
|
||||
other.id == id &&
|
||||
other.name == name &&
|
||||
other.type == type &&
|
||||
other.size == size &&
|
||||
other.isInline == isInline;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, name, type, size, isInline);
|
||||
}
|
||||
|
||||
/// 우리 서버가 넣은 대용량 첨부 하나 — 계약 4절 `MessageLargeFile` (첫 판은 받지 않고 이름 · 크기만 보인다)
|
||||
@immutable
|
||||
class MessageLargeFile {
|
||||
const MessageLargeFile({
|
||||
required this.fileId,
|
||||
required this.name,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
final String fileId;
|
||||
|
||||
/// 서버 기록의 이름
|
||||
final String name;
|
||||
|
||||
/// 바이트
|
||||
final int size;
|
||||
|
||||
static MessageLargeFile? tryParse(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final Object? fileId = json['fileId'];
|
||||
final Object? name = json['name'];
|
||||
final Object? size = json['size'];
|
||||
if (fileId is! String || name is! String || size is! num) return null;
|
||||
return MessageLargeFile(fileId: fileId, name: name, size: size.toInt());
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is MessageLargeFile &&
|
||||
other.fileId == fileId &&
|
||||
other.name == name &&
|
||||
other.size == size;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(fileId, name, size);
|
||||
}
|
||||
|
||||
/// 메일 한 통 — 계약 2절 `MessageDetail` (`GET /api/messages/{id}`)
|
||||
///
|
||||
/// 목록 칸([summary])과 읽기 칸을 함께 둔다. 9단계 뒤에 더해진 칸(`imagesShown` · `isHtmlBodyOmitted` · `isBodyTruncated` ·
|
||||
/// `largeFiles` · 첨부의 `isInline`)은 없으면 거짓 · 빈 목록으로 읽는다(웹과 같다). 대용량 첨부는 하나가 틀려도 그것만 뺀다.
|
||||
@immutable
|
||||
class MessageDetail {
|
||||
const MessageDetail({
|
||||
required this.summary,
|
||||
this.cc = const <MailAddress>[],
|
||||
this.bcc = const <MailAddress>[],
|
||||
this.sentAt,
|
||||
this.htmlBody,
|
||||
this.textBody,
|
||||
this.hasBlockedImages = false,
|
||||
this.imagesShown = false,
|
||||
this.isHtmlBodyOmitted = false,
|
||||
this.isBodyTruncated = false,
|
||||
this.attachments = const <MessageAttachment>[],
|
||||
this.largeFiles = const <MessageLargeFile>[],
|
||||
});
|
||||
|
||||
/// 목록과 같은 칸 — 읽음 · 중요 · 메일함 · 붙잡힌 발송
|
||||
final MessageSummary summary;
|
||||
final List<MailAddress> cc;
|
||||
|
||||
/// 숨은참조 — 내가 보낸 메일에만 온다
|
||||
final List<MailAddress> bcc;
|
||||
final DateTime? sentAt;
|
||||
|
||||
/// 서버가 정화한 HTML — 앱은 한 번 더 정화한다(`mail_html.dart`)
|
||||
final String? htmlBody;
|
||||
final String? textBody;
|
||||
|
||||
/// 외부 이미지를 지웠다 — "외부 이미지 차단" 줄
|
||||
final bool hasBlockedImages;
|
||||
|
||||
/// 외부 이미지를 중계 주소로 바꿔 넣었다(`images=show` 또는 설정) — 앱이 그 그림을 받아 넣는다
|
||||
final bool imagesShown;
|
||||
|
||||
/// 서식 본문이 한도를 넘어 비웠다 — 글자 본문으로 보인다
|
||||
final bool isHtmlBodyOmitted;
|
||||
|
||||
/// 보여 줄 본문이 읽기 한도에서 잘렸다
|
||||
final bool isBodyTruncated;
|
||||
final List<MessageAttachment> attachments;
|
||||
final List<MessageLargeFile> largeFiles;
|
||||
|
||||
String get id => summary.id;
|
||||
|
||||
/// 첨부 목록에 보일 것 — 본문 안에 그림으로 들어간 것은 뺀다(웹 `listedAttachments`)
|
||||
List<MessageAttachment> get listedAttachments => <MessageAttachment>[
|
||||
for (final MessageAttachment attachment in attachments)
|
||||
if (!attachment.isInline) attachment,
|
||||
];
|
||||
|
||||
/// 목록 칸만 바꾼다 — 읽음 · 중요 · 메일함 · 붙잡힌 발송(본문은 그대로)
|
||||
MessageDetail withSummary(MessageSummary next) => MessageDetail(
|
||||
summary: next,
|
||||
cc: cc,
|
||||
bcc: bcc,
|
||||
sentAt: sentAt,
|
||||
htmlBody: htmlBody,
|
||||
textBody: textBody,
|
||||
hasBlockedImages: hasBlockedImages,
|
||||
imagesShown: imagesShown,
|
||||
isHtmlBodyOmitted: isHtmlBodyOmitted,
|
||||
isBodyTruncated: isBodyTruncated,
|
||||
attachments: attachments,
|
||||
largeFiles: largeFiles,
|
||||
);
|
||||
|
||||
/// 본문 칸만 [other] 의 것으로 — [이미지 보기]로 다시 받은 본문(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹 `showImages`)
|
||||
MessageDetail withBodyOf(MessageDetail other) => MessageDetail(
|
||||
summary: summary,
|
||||
cc: cc,
|
||||
bcc: bcc,
|
||||
sentAt: sentAt,
|
||||
htmlBody: other.htmlBody,
|
||||
textBody: other.textBody,
|
||||
hasBlockedImages: other.hasBlockedImages,
|
||||
imagesShown: other.imagesShown,
|
||||
isHtmlBodyOmitted: other.isHtmlBodyOmitted,
|
||||
isBodyTruncated: other.isBodyTruncated,
|
||||
attachments: other.attachments,
|
||||
largeFiles: other.largeFiles,
|
||||
);
|
||||
|
||||
/// 모양이 틀리면 null — 저장소가 `SYS_001` 로 던진다
|
||||
static MessageDetail? tryParse(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final MessageSummary? summary = MessageSummary.tryParse(json);
|
||||
if (summary == null) return null;
|
||||
final List<MailAddress>? cc = _addresses(json['cc']);
|
||||
final List<MailAddress>? bcc = _addresses(json['bcc']);
|
||||
final Object? sentAt = json['sentAt'];
|
||||
final Object? htmlBody = json['htmlBody'];
|
||||
final Object? textBody = json['textBody'];
|
||||
final List<MessageAttachment>? attachments = _attachments(
|
||||
json['attachments'],
|
||||
);
|
||||
final bool? hasBlockedImages = _flag(json['hasBlockedImages']);
|
||||
final bool? imagesShown = _flag(json['imagesShown']);
|
||||
final bool? omitted = _flag(json['isHtmlBodyOmitted']);
|
||||
final bool? truncated = _flag(json['isBodyTruncated']);
|
||||
final DateTime? sent = sentAt is String ? DateTime.tryParse(sentAt) : null;
|
||||
if (cc == null ||
|
||||
bcc == null ||
|
||||
(sentAt != null && sent == null) ||
|
||||
(htmlBody != null && htmlBody is! String) ||
|
||||
(textBody != null && textBody is! String) ||
|
||||
attachments == null ||
|
||||
hasBlockedImages == null ||
|
||||
imagesShown == null ||
|
||||
omitted == null ||
|
||||
truncated == null) {
|
||||
return null;
|
||||
}
|
||||
return MessageDetail(
|
||||
summary: summary,
|
||||
cc: cc,
|
||||
bcc: bcc,
|
||||
sentAt: sent?.toUtc(),
|
||||
htmlBody: htmlBody as String?,
|
||||
textBody: textBody as String?,
|
||||
hasBlockedImages: hasBlockedImages,
|
||||
imagesShown: imagesShown,
|
||||
isHtmlBodyOmitted: omitted,
|
||||
isBodyTruncated: truncated,
|
||||
attachments: attachments,
|
||||
largeFiles: _largeFiles(json['largeFiles']),
|
||||
);
|
||||
}
|
||||
|
||||
/// 주소 칸 — 없으면 빈 목록, 모양이 틀리면 null
|
||||
static List<MailAddress>? _addresses(Object? value) =>
|
||||
value == null ? const <MailAddress>[] : MailAddress.tryParseList(value);
|
||||
|
||||
/// 참 · 거짓 칸 — 없으면 거짓, 모양이 틀리면 null
|
||||
static bool? _flag(Object? value) {
|
||||
if (value == null) return false;
|
||||
return value is bool ? value : null;
|
||||
}
|
||||
|
||||
static List<MessageAttachment>? _attachments(Object? value) {
|
||||
if (value == null) return const <MessageAttachment>[];
|
||||
if (value is! List<Object?>) return null;
|
||||
final List<MessageAttachment> items = <MessageAttachment>[];
|
||||
for (final Object? item in value) {
|
||||
final MessageAttachment? attachment = MessageAttachment.tryParse(item);
|
||||
if (attachment == null) return null;
|
||||
items.add(attachment);
|
||||
}
|
||||
return List<MessageAttachment>.unmodifiable(items);
|
||||
}
|
||||
|
||||
/// 대용량 첨부 — 11단계 칸이라 모양이 틀린 것은 빼고 읽는다(메일 전체를 열지 못하게 하지 않는다)
|
||||
static List<MessageLargeFile> _largeFiles(Object? value) {
|
||||
if (value is! List<Object?>) return const <MessageLargeFile>[];
|
||||
return List<MessageLargeFile>.unmodifiable(<MessageLargeFile>[
|
||||
for (final Object? item in value) ?MessageLargeFile.tryParse(item),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import 'dart:async';
|
||||
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:arasystem_app/core/api/request_flags.dart';
|
||||
import 'package:arasystem_app/core/api/server_time.dart';
|
||||
import 'package:arasystem_app/core/files/downloaded_files.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
@@ -20,8 +26,24 @@ abstract final class MailApiPaths {
|
||||
|
||||
/// 새 메일 확인 — 불투명한 상태 글
|
||||
static const String mailState = '/mail-state';
|
||||
|
||||
/// 첨부 받기 — 표준 래퍼 없는 파일
|
||||
static String attachment(String messageId, String attachmentId) =>
|
||||
'${message(messageId)}/attachments/${Uri.encodeComponent(attachmentId)}';
|
||||
|
||||
/// 붙잡아 둔 발송 취소(보내기 취소 · 예약 취소) — id 는 보낸 사본의 메일 id (9단계)
|
||||
static String scheduledMessage(String id) =>
|
||||
'/scheduled-messages/${Uri.encodeComponent(id)}';
|
||||
|
||||
/// 외부 이미지 중계 — 서버가 본문에 넣은 주소(`/api/mail-images/<표>`)의 표 (9단계)
|
||||
static String mailImage(String token) =>
|
||||
'/mail-images/${Uri.encodeComponent(token)}';
|
||||
}
|
||||
|
||||
/// 메일 한 통을 외부 이미지가 보이는 본문으로 받는 쿼리 — `?images=show` (계약 2절 "외부 이미지 보기")
|
||||
const String kMailImagesQuery = 'images';
|
||||
const String kMailImagesShow = 'show';
|
||||
|
||||
/// 목록 한 쪽의 기본 수 — 계약 기본값(20)과 같다
|
||||
const int kMailPageSize = 20;
|
||||
|
||||
@@ -76,14 +98,35 @@ class MailRepository {
|
||||
return page.withServerTime(serverTimeOf(response));
|
||||
}
|
||||
|
||||
/// 한 통 바꾸기 — 읽음 · 중요 · 옮기기 · 스팸 (읽기 화면이 쓴다)
|
||||
/// 메일 한 통 — 조회만으로는 읽음 처리하지 않는다(계약 2절). [showImages] 면 외부 이미지를 보이는 본문(`images=show`).
|
||||
/// 알림은 [silent] 로 끈다 — 처음 여는 메일은 화면이 받지 못함을 직접 그린다. 응답의 서버 시각(`Date`)도 함께 준다
|
||||
Future<({MessageDetail detail, DateTime? serverTime})> getMessage(
|
||||
String id, {
|
||||
bool showImages = false,
|
||||
bool silent = true,
|
||||
}) async {
|
||||
final Response<Object?> response = await _dio.get<Object?>(
|
||||
MailApiPaths.message(id),
|
||||
queryParameters: showImages
|
||||
? <String, Object?>{kMailImagesQuery: kMailImagesShow}
|
||||
: null,
|
||||
options: silent ? ApiOptions.silent() : null,
|
||||
);
|
||||
final MessageDetail? detail = MessageDetail.tryParse(response.data);
|
||||
if (detail == null) throw _malformed(response);
|
||||
return (detail: detail, serverTime: serverTimeOf(response));
|
||||
}
|
||||
|
||||
/// 한 통 바꾸기 — 읽음 · 중요 · 옮기기 · 스팸 (읽기 화면이 쓴다). [silent] 면 실패를 알리지 않는다(연 메일의 읽음 처리)
|
||||
Future<MessageSummary> updateMessage(
|
||||
String id,
|
||||
MessageChanges changes,
|
||||
) async {
|
||||
MessageChanges changes, {
|
||||
bool silent = false,
|
||||
}) async {
|
||||
final Response<Object?> response = await _dio.patch<Object?>(
|
||||
MailApiPaths.message(id),
|
||||
data: changes.toJson(),
|
||||
options: silent ? ApiOptions.silent() : null,
|
||||
);
|
||||
final MessageSummary? summary = MessageSummary.tryParse(response.data);
|
||||
if (summary == null) throw _malformed(response);
|
||||
@@ -127,6 +170,130 @@ class MailRepository {
|
||||
return state;
|
||||
}
|
||||
|
||||
/// 붙잡아 둔 발송을 취소한다(보내기 취소 · 예약 취소) — 메일은 보내지 않고 임시보관함으로 간다. 이어 쓸 초안 id(같은 메일 id)를 준다.
|
||||
/// 이미 보냈으면 409 `MAIL_014`, 없으면 404 `RES_001` — 알림은 인터셉터가 띄운다 (계약 2절 "보내기 취소 · 예약 발송")
|
||||
Future<String> cancelPendingSend(String id) async {
|
||||
final Response<Object?> response = await _dio.delete<Object?>(
|
||||
MailApiPaths.scheduledMessage(id),
|
||||
);
|
||||
final Object? data = response.data;
|
||||
final Object? draftId = data is Map<String, Object?>
|
||||
? data['draftId']
|
||||
: null;
|
||||
if (draftId is! String || draftId.isEmpty) throw _malformed(response);
|
||||
return draftId;
|
||||
}
|
||||
|
||||
/// 외부 이미지 하나 — 서버가 바깥에서 대신 받아 준 그림(바이트). 실패는 알리지 않는다(그 자리를 비운다).
|
||||
/// [maxBytes] 를 넘게 오면 받기를 끊는다(취소 — `DioExceptionType.cancel`). [cancelToken] 을 그만두면(화면을 떠남 · 본문이 바뀜) 이 받기도 끊는다 —
|
||||
/// 한도로 끊는 토큰은 그림마다 따로 둔다(한 그림의 한도가 다른 그림을 끊지 않게)
|
||||
Future<Uint8List> fetchMailImage(
|
||||
String token, {
|
||||
required int maxBytes,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
final CancelToken cancel = CancelToken();
|
||||
if (cancelToken != null) {
|
||||
if (cancelToken.isCancelled) cancel.cancel();
|
||||
unawaited(
|
||||
cancelToken.whenCancel.then((DioException _) {
|
||||
if (!cancel.isCancelled) cancel.cancel();
|
||||
}),
|
||||
);
|
||||
}
|
||||
final Response<List<int>> response = await _dio.get<List<int>>(
|
||||
MailApiPaths.mailImage(token),
|
||||
options: Options(
|
||||
responseType: ResponseType.bytes,
|
||||
extra: <String, Object?>{kSilentExtraKey: true},
|
||||
),
|
||||
cancelToken: cancel,
|
||||
onReceiveProgress: (int received, int total) {
|
||||
if (received > maxBytes || total > maxBytes) cancel.cancel();
|
||||
},
|
||||
);
|
||||
final List<int>? data = response.data;
|
||||
if (data == null) {
|
||||
throw apiFailure(response.requestOptions, kFallbackCode);
|
||||
}
|
||||
return data is Uint8List ? data : Uint8List.fromList(data);
|
||||
}
|
||||
|
||||
/// 첨부 하나를 [target] 에 받는다 — 조각마다 [onProgress](받은 바이트 · 전체 바이트 — 모르면 null).
|
||||
///
|
||||
/// 실패는 늘 `DioException` 이다 — 서버가 거절했으면 인터셉터가 코드로 알렸고(실패 본문도 코드로 읽는다),
|
||||
/// 받는 도중 끊기면 `SYS_001`(이때는 응답이 이미 지나가 인터셉터가 알리지 않는다 — [DownloadInterrupted]).
|
||||
/// [cancelToken] 으로 그만두면 `DioExceptionType.cancel` 이다. 쓰다 만 [target] 은 부르는 쪽이 지운다
|
||||
Future<void> downloadAttachment({
|
||||
required String messageId,
|
||||
required String attachmentId,
|
||||
required DownloadTarget target,
|
||||
void Function(int received, int? total)? onProgress,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
final Response<ResponseBody> response = await _dio.get<ResponseBody>(
|
||||
MailApiPaths.attachment(messageId, attachmentId),
|
||||
options: Options(responseType: ResponseType.stream),
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
final ResponseBody? body = response.data;
|
||||
if (body == null) {
|
||||
throw apiFailure(response.requestOptions, kFallbackCode);
|
||||
}
|
||||
final int? length = int.tryParse(
|
||||
response.headers.value(Headers.contentLengthHeader) ?? '',
|
||||
);
|
||||
final int? total = length != null && length > 0 ? length : null;
|
||||
final RequestOptions request = response.requestOptions;
|
||||
final Completer<void> done = Completer<void>();
|
||||
int received = 0;
|
||||
final StreamSubscription<Uint8List> subscription = body.stream.listen(
|
||||
(Uint8List chunk) {
|
||||
target.add(chunk);
|
||||
received += chunk.length;
|
||||
onProgress?.call(received, total);
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
if (done.isCompleted) return;
|
||||
// 받는 도중의 실패(끊김 · 조각 사이 받기 한도 `receiveTimeout`)는 인터셉터를 지나지 않는다 — 그만둔 것만 그대로 둔다
|
||||
final bool cancelled =
|
||||
error is DioException && error.type == DioExceptionType.cancel;
|
||||
done.completeError(
|
||||
cancelled ? error : DownloadInterrupted(request),
|
||||
stackTrace,
|
||||
);
|
||||
},
|
||||
onDone: () {
|
||||
if (!done.isCompleted) done.complete();
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
// 그만두면 다음 조각을 기다리지 않고 바로 끊는다
|
||||
unawaited(
|
||||
cancelToken?.whenCancel.then((DioException _) async {
|
||||
if (done.isCompleted) return;
|
||||
done.completeError(
|
||||
DioException.requestCancelled(
|
||||
requestOptions: request,
|
||||
reason: cancelToken.cancelError?.error,
|
||||
),
|
||||
);
|
||||
await subscription.cancel();
|
||||
}),
|
||||
);
|
||||
await done.future;
|
||||
}
|
||||
|
||||
DioException _malformed(Response<Object?> response) =>
|
||||
apiFailure(response.requestOptions, kFallbackCode);
|
||||
}
|
||||
|
||||
/// 첨부를 받는 도중 끊겼다 — 응답은 이미 인터셉터를 지나서 아무도 알리지 않았다(부르는 쪽이 `SYS_001` 로 알린다)
|
||||
class DownloadInterrupted extends DioException {
|
||||
DownloadInterrupted(RequestOptions requestOptions)
|
||||
: super(
|
||||
requestOptions: requestOptions,
|
||||
type: DioExceptionType.connectionError,
|
||||
error: ApiException.fromCode(kFallbackCode),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/// 쓰기 화면에 넘길 값 — 읽기 화면의 [답장] · [전체 답장] · [전달] · 본문의 mailto 링크 (순수 함수).
|
||||
///
|
||||
/// 쓰기 화면(`App1Compose`)은 다음 차례다. 지금은 가는 길(주소 · 쿼리)과 넘길 값만 정해 둔다 —
|
||||
/// 주소는 웹과 같은 쿼리(`/mail/compose?replyTo=<id>` · `&all=1` · `?forward=<id>` · `?draft=<id>`)이고,
|
||||
/// mailto 의 받는 사람 · 제목 · 본문은 주소에 싣지 않고 라우트의 `extra`([ComposePrefill])로 넘긴다
|
||||
/// (웹 `compose-prefill.ts` 처럼 — 주소가 기록 · 로그에 남지 않게).
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 쓰기 화면 주소의 쿼리 이름 — 웹 `/mail/compose` 와 같다
|
||||
abstract final class ComposeQuery {
|
||||
static const String draft = 'draft';
|
||||
static const String replyTo = 'replyTo';
|
||||
|
||||
/// [replyTo] 와 함께 `1` 이면 전체 답장
|
||||
static const String replyAll = 'all';
|
||||
static const String replyAllValue = '1';
|
||||
static const String forward = 'forward';
|
||||
}
|
||||
|
||||
/// 쓰기 화면을 여는 까닭
|
||||
sealed class ComposeIntent {
|
||||
const ComposeIntent();
|
||||
}
|
||||
|
||||
/// 새 메일 (mailto 로 채운 것 포함 — 채울 값은 [ComposePrefill])
|
||||
final class NewComposeIntent extends ComposeIntent {
|
||||
const NewComposeIntent();
|
||||
}
|
||||
|
||||
/// 임시 저장본 이어 쓰기
|
||||
final class DraftComposeIntent extends ComposeIntent {
|
||||
const DraftComposeIntent(this.draftId);
|
||||
|
||||
final String draftId;
|
||||
}
|
||||
|
||||
/// 답장 · 전체 답장
|
||||
final class ReplyComposeIntent extends ComposeIntent {
|
||||
const ReplyComposeIntent(this.messageId, {required this.all});
|
||||
|
||||
final String messageId;
|
||||
final bool all;
|
||||
}
|
||||
|
||||
/// 전달
|
||||
final class ForwardComposeIntent extends ComposeIntent {
|
||||
const ForwardComposeIntent(this.messageId);
|
||||
|
||||
final String messageId;
|
||||
}
|
||||
|
||||
/// 쓰기 화면 주소의 쿼리 → 여는 까닭 (모르는 값은 새 메일 — 웹과 같은 차례: 이어 쓰기 → 답장 → 전달)
|
||||
ComposeIntent composeIntentOf(Map<String, String> query) {
|
||||
final String? draft = query[ComposeQuery.draft];
|
||||
if (draft != null && draft.isNotEmpty) return DraftComposeIntent(draft);
|
||||
final String? replyTo = query[ComposeQuery.replyTo];
|
||||
if (replyTo != null && replyTo.isNotEmpty) {
|
||||
return ReplyComposeIntent(
|
||||
replyTo,
|
||||
all: query[ComposeQuery.replyAll] == ComposeQuery.replyAllValue,
|
||||
);
|
||||
}
|
||||
final String? forward = query[ComposeQuery.forward];
|
||||
if (forward != null && forward.isNotEmpty) {
|
||||
return ForwardComposeIntent(forward);
|
||||
}
|
||||
return const NewComposeIntent();
|
||||
}
|
||||
|
||||
/// mailto 링크로 채울 값 — 쓰기 화면이 받는 사람 · 제목 · 본문 칸을 채운다
|
||||
@immutable
|
||||
class ComposePrefill {
|
||||
const ComposePrefill({
|
||||
this.to = const <String>[],
|
||||
this.cc = const <String>[],
|
||||
this.bcc = const <String>[],
|
||||
this.subject = '',
|
||||
this.body = '',
|
||||
});
|
||||
|
||||
final List<String> to;
|
||||
final List<String> cc;
|
||||
final List<String> bcc;
|
||||
final String subject;
|
||||
final String body;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is ComposePrefill &&
|
||||
listEquals(other.to, to) &&
|
||||
listEquals(other.cc, cc) &&
|
||||
listEquals(other.bcc, bcc) &&
|
||||
other.subject == subject &&
|
||||
other.body == body;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
Object.hashAll(to),
|
||||
Object.hashAll(cc),
|
||||
Object.hashAll(bcc),
|
||||
subject,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
/// 받는 사람 합계 한도 — 발송 한도와 같다(계약 2절 — 100명)
|
||||
const int kComposeMaxRecipients = 100;
|
||||
|
||||
/// 제목 한도 — 발송 한도와 같다(255자)
|
||||
const int kComposeMaxSubject = 255;
|
||||
|
||||
/// mailto 로 채우는 본문 한도 — 링크에 실린 긴 글로 화면이 무거워지지 않게(발송 한도 100,000자보다 짧게 둔다)
|
||||
const int kComposeMaxPrefillBody = 10000;
|
||||
|
||||
/// 주소 한 칸의 최대 길이 (RFC 5321)
|
||||
const int _maxAddressLength = 254;
|
||||
|
||||
/// mailto 링크 → 채울 값 — mailto 가 아니면 null.
|
||||
///
|
||||
/// `mailto:a@x,b@x?cc=…&bcc=…&subject=…&body=…`(RFC 6068). 받는 사람은 `@` 가 있고 공백 · 제어 문자가 없는 것만,
|
||||
/// 겹치면 하나, 합계 [kComposeMaxRecipients] 까지. 제목 · 본문은 한도에서 자른다. 받는 사람이 없어도 된다(빈 쓰기 화면).
|
||||
ComposePrefill? composePrefillOfMailto(Uri uri) {
|
||||
if (uri.scheme.toLowerCase() != 'mailto') return null;
|
||||
final Map<String, String> query = <String, String>{};
|
||||
final String rawQuery = uri.hasQuery ? uri.query : '';
|
||||
if (rawQuery.isNotEmpty) {
|
||||
for (final String part in rawQuery.split('&')) {
|
||||
final int equals = part.indexOf('=');
|
||||
final String key = _decode(
|
||||
equals == -1 ? part : part.substring(0, equals),
|
||||
).toLowerCase();
|
||||
final String value = equals == -1
|
||||
? ''
|
||||
: _decode(part.substring(equals + 1));
|
||||
// 같은 이름이 여럿이면 앞의 것 — 받는 사람 칸은 이어 붙인다
|
||||
if (key == 'to' || key == 'cc' || key == 'bcc') {
|
||||
query[key] = query[key] == null ? value : '${query[key]},$value';
|
||||
} else {
|
||||
query.putIfAbsent(key, () => value);
|
||||
}
|
||||
}
|
||||
}
|
||||
final Set<String> seen = <String>{};
|
||||
List<String> addresses(String raw) {
|
||||
final List<String> out = <String>[];
|
||||
for (final String piece in raw.split(',')) {
|
||||
final String address = piece.trim();
|
||||
if (!_isPlausibleAddress(address)) continue;
|
||||
if (seen.length >= kComposeMaxRecipients) break;
|
||||
if (!seen.add(address.toLowerCase())) continue;
|
||||
out.add(address);
|
||||
}
|
||||
return List<String>.unmodifiable(out);
|
||||
}
|
||||
|
||||
return ComposePrefill(
|
||||
to: addresses(<String>[_decode(uri.path), query['to'] ?? ''].join(',')),
|
||||
cc: addresses(query['cc'] ?? ''),
|
||||
bcc: addresses(query['bcc'] ?? ''),
|
||||
subject: _limit(_singleLine(query['subject'] ?? ''), kComposeMaxSubject),
|
||||
body: _limit(query['body'] ?? '', kComposeMaxPrefillBody),
|
||||
);
|
||||
}
|
||||
|
||||
/// 퍼센트 인코딩을 푼다 — 깨진 인코딩은 그대로 둔다
|
||||
String _decode(String value) {
|
||||
try {
|
||||
return Uri.decodeComponent(value);
|
||||
} on ArgumentError {
|
||||
return value;
|
||||
} on FormatException {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isPlausibleAddress(String address) {
|
||||
if (address.isEmpty || address.length > _maxAddressLength) return false;
|
||||
final int at = address.indexOf('@');
|
||||
if (at <= 0 || at != address.lastIndexOf('@') || at == address.length - 1) {
|
||||
return false;
|
||||
}
|
||||
return !address.runes.any(
|
||||
(int rune) => rune <= 0x20 || (rune >= 0x7F && rune <= 0x9F),
|
||||
);
|
||||
}
|
||||
|
||||
/// 제목은 한 줄 — 줄바꿈 · 제어 문자는 띄어쓰기로
|
||||
String _singleLine(String value) =>
|
||||
value.replaceAll(RegExp(r'[\u0000-\u001F\u007F]+'), ' ').trim();
|
||||
|
||||
/// [max] 자(UTF-16)에서 자른다 — 이모지 같은 짝 글자(서로게이트)를 가르지 않는다
|
||||
String _limit(String value, int max) {
|
||||
if (value.length <= max) return value;
|
||||
final int end = _isHighSurrogate(value.codeUnitAt(max - 1)) ? max - 1 : max;
|
||||
return value.substring(0, end);
|
||||
}
|
||||
|
||||
bool _isHighSurrogate(int unit) => unit >= 0xD800 && unit <= 0xDBFF;
|
||||
@@ -0,0 +1,32 @@
|
||||
/// 읽기 화면의 본문 안내 — 서버가 본문을 다 주지 못했을 때 (계약 2절 "읽기 한도" · 웹 `shared/utils/mail-body-notices.ts` 와 같다).
|
||||
///
|
||||
/// 차례는 생략(서식 없이 · 표시할 수 없음) → 잘림이다. 보여 줄 본문이 없으면("표시할 수 없음") 잘림 안내는 맞지 않아 뺀다.
|
||||
/// 글자 본문은 공백뿐이면 없는 것으로 본다.
|
||||
library;
|
||||
|
||||
/// 본문 안내의 종류
|
||||
enum MailBodyNotice {
|
||||
/// 서식 본문을 한도 때문에 비웠다 — 글자 본문을 보인다
|
||||
htmlOmitted,
|
||||
|
||||
/// 서식 본문을 비웠고 글자 본문도 없다
|
||||
unavailable,
|
||||
|
||||
/// 보여 주는 본문이 읽기 한도에서 잘렸다
|
||||
truncated,
|
||||
}
|
||||
|
||||
/// 본문 위에 둘 안내 — 없으면 빈 목록
|
||||
List<MailBodyNotice> mailBodyNoticesOf({
|
||||
required String? textBody,
|
||||
required bool isHtmlBodyOmitted,
|
||||
required bool isBodyTruncated,
|
||||
}) {
|
||||
final bool hasText = (textBody ?? '').trim().isNotEmpty;
|
||||
return <MailBodyNotice>[
|
||||
if (isHtmlBodyOmitted)
|
||||
hasText ? MailBodyNotice.htmlOmitted : MailBodyNotice.unavailable,
|
||||
if (isBodyTruncated && !(isHtmlBodyOmitted && !hasText))
|
||||
MailBodyNotice.truncated,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
/// 받은 메일 본문 → WebView 에 넣을 문서 — 보안 핵심 (순수 함수 · 웹 `shared/utils/mail-html.ts` 의 결을 따른다).
|
||||
///
|
||||
/// 방어는 세 겹이다. 어느 한 겹이 뚫려도 나머지가 막도록 셋 다 유지한다.
|
||||
/// 1) 서버가 정화한 htmlBody (계약 2절 "htmlBody 정화 규칙")
|
||||
/// 2) 여기서 한 번 더 정화 — HTML5 파서(`package:html`)로 읽어 **허용 목록의 태그 · 속성만** 다시 쓴다.
|
||||
/// 서버 정화에 빈틈이 있어도 스크립트 · 폼 · 외부 자원 · 이벤트 처리기를 걷어 낸다. 결과 HTML 은 파서의 직렬화가 아니라
|
||||
/// 여기서 직접 쓴다(속성 값의 `<` `>` `"` `&` 를 늘 이스케이프 — 아래 그림 주소 바꿔 끼우기가 그 모양에 기댄다).
|
||||
/// 3) WebView — 스크립트를 끄고(`JavaScriptMode.disabled`), 이 문서 안의 CSP 로 모든 바깥 요청 · 아래 틀을 막고(그림은 `data:` 만),
|
||||
/// 주 틀의 탐색은 모두 막는다(링크는 앱이 바깥 브라우저 · 쓰기 화면으로 — `mail_links.dart`). 아래 틀(iframe)의 탐색은 안드로이드가
|
||||
/// 판정에 넘기지 않으므로 정화(iframe 을 내용째 버림)와 CSP(frame-src)만 막는다.
|
||||
///
|
||||
/// 외부 이미지를 보일 때(서버가 중계 주소 `/api/mail-images/<표>` 로 바꿔 넣은 본문 — imagesShown)도 WebView 는 바깥에 나가지 않는다 —
|
||||
/// 앱이 Dio(Bearer)로 그림을 받아 `data:` 로 바꿔 넣는다([resolveProxiedImages]). 토큰이 WebView 에 가지 않는다.
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/features/mail/domain/mail_images.dart';
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
|
||||
/// WebView 문서 안의 CSP — 스크립트 · 글꼴 · 네트워크 요청을 모두 막고 `data:` 그림과 인라인 스타일만 연다.
|
||||
/// 웹(`MAIL_FRAME_CSP`)과 같고, `<base>` · 폼 보내기도 막는다(둘은 `default-src` 를 따르지 않는다).
|
||||
/// 외부 이미지를 보일 때도 같다 — 그림은 앱이 받아 `data:` 로 넣는다(웹처럼 중계 경로를 열지 않는다)
|
||||
const String kMailDocumentCsp =
|
||||
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'";
|
||||
|
||||
/// 서버의 중계 주소 — `/api/mail-images/<표>`. 표는 서버가 암호화한 불투명 글(base64url)이라 한 칸뿐이다
|
||||
/// (짝: backend `modules/mail/images/mail-image-token.ts` 의 `TOKEN_PATTERN` · 웹 `PROXIED_IMAGE_PATTERN`)
|
||||
final RegExp kProxiedImagePattern = RegExp(
|
||||
r'^/api/mail-images/([A-Za-z0-9_-]+)$',
|
||||
);
|
||||
|
||||
/// 한 통에서 받아 넣을 중계 그림의 최대 수 — 서버가 한 통에 바꿔 넣는 수와 같다(계약 2절 "외부 이미지 보기" — 100개)
|
||||
const int kMaxProxiedImages = 100;
|
||||
|
||||
/// 남길 링크 — 계약과 같다(http · https · mailto). 상대 경로 · javascript: 등은 지운다
|
||||
final RegExp _safeLink = RegExp(r'^(?:https?|mailto):', caseSensitive: false);
|
||||
|
||||
/// 남길 `data:` 그림 — 서버가 본문에 넣어 주는 그림 형식(PNG · JPEG · GIF · WebP)만. SVG 는 받지 않는다
|
||||
final RegExp _dataImage = RegExp(
|
||||
r'^data:image/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+/=\s]*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// 남기는 태그 — 메일 본문에 흔한 글 · 표 · 목록 · 인용 태그(DOMPurify 의 HTML 목록에서 폼 · 미디어 · 틀을 뺐다)
|
||||
const Set<String> _allowedTags = <String>{
|
||||
'a', 'abbr', 'acronym', 'address', 'article', 'aside', 'b', 'bdi', 'bdo', //
|
||||
'big', 'blockquote', 'br', 'caption', 'center', 'cite', 'code', 'col', //
|
||||
'colgroup', 'data', 'dd', 'del', 'details', 'dfn', 'dir', 'div', 'dl', //
|
||||
'dt', 'em', 'figcaption', 'figure', 'font', 'footer', 'h1', 'h2', 'h3', //
|
||||
'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'i', 'img', 'ins', 'kbd', //
|
||||
'li', 'main', 'mark', 'menu', 'nav', 'nobr', 'ol', 'p', 'pre', 'q', 'rp', //
|
||||
'rt', 'ruby', 's', 'samp', 'section', 'small', 'span', 'strike', 'strong', //
|
||||
'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', //
|
||||
'time', 'tr', 'tt', 'u', 'ul', 'var', 'wbr',
|
||||
};
|
||||
|
||||
/// 내용째 버리는 태그 — 스크립트 · 스타일 · 틀 · 미디어 · SVG · 수식 · 폼의 숨은 값(웹 `FORBID_CONTENTS` 와 서버 제거 목록)
|
||||
const Set<String> _droppedWithContent = <String>{
|
||||
'script', 'style', 'template', 'iframe', 'frame', 'frameset', 'object', //
|
||||
'embed', 'applet', 'param', 'noscript', 'noembed', 'noframes', 'svg', //
|
||||
'math', 'datalist', 'title', 'head', 'xmp', 'plaintext', 'audio', //
|
||||
'video', 'source', 'track', 'canvas', 'map', 'area', 'link', 'meta', //
|
||||
'base', 'input', 'select', 'dialog', 'slot',
|
||||
};
|
||||
|
||||
/// 내용이 없는 태그 — 닫는 태그를 쓰지 않는다
|
||||
const Set<String> _voidTags = <String>{'br', 'col', 'hr', 'img', 'wbr'};
|
||||
|
||||
/// 어느 태그에나 남기는 속성 — 모양(style) · 표 · 글꼴의 옛 속성. 이벤트 처리기 · data-* · id · target · 외부 자원 속성은 없다
|
||||
const Set<String> _globalAttributes = <String>{
|
||||
'style', 'class', 'title', 'dir', 'lang', 'align', 'valign', 'width', //
|
||||
'height', 'bgcolor', 'color', 'border', 'cellpadding', 'cellspacing', //
|
||||
'colspan', 'rowspan', 'span', 'face', 'size', 'nowrap', 'frame', 'rules', //
|
||||
'summary', 'abbr', 'headers', 'scope', 'hspace', 'vspace', 'clear', //
|
||||
'noshade', 'datetime',
|
||||
};
|
||||
|
||||
/// 태그마다 더 남기는 속성
|
||||
const Map<String, Set<String>> _tagAttributes = <String, Set<String>>{
|
||||
'a': <String>{'href'},
|
||||
'img': <String>{'src', 'alt'},
|
||||
'ol': <String>{'start', 'type', 'reversed'},
|
||||
'ul': <String>{'type'},
|
||||
'li': <String>{'type', 'value'},
|
||||
'details': <String>{'open'},
|
||||
};
|
||||
|
||||
/// 속성 값의 최대 길이 — 넘으면 그 속성을 뺀다(`style` 은 표 꾸밈이 길 수 있어 넉넉히 · 주소는 따로)
|
||||
const int _maxAttributeLength = 1000;
|
||||
const int _maxStyleLength = 4000;
|
||||
|
||||
/// 링크 주소의 최대 길이 — 중계 주소 한도(2,048자)와 같다
|
||||
const int _maxHrefLength = 2048;
|
||||
|
||||
/// 앱이 정화하는 서식 본문의 최대 길이(글자) — 서버의 읽기 한도와 같다(계약 2절 "읽기 한도" — 2,000,000)
|
||||
const int kMaxSanitizedHtmlLength = 2000000;
|
||||
|
||||
/// 앱이 정화하는 서식 본문의 최대 중첩 비용 — 태그마다 그때의 깊이를 더한 값([htmlNestingCost]).
|
||||
///
|
||||
/// HTML5 파서(`package:html`)는 태그마다 열린 요소를 훑어(범위 검사) 깊이에 비례해 일하므로 깊은 본문은 제곱으로 느려진다
|
||||
/// (2026-11-06 이 PC 의 시험 판: 깊이 200 · 2MB 2.2초, 깊이 600 · 2MB 6.4초, div 5,000단 55KB 3.8초 — 서버는 2만 단까지 넘긴다).
|
||||
/// 이 값이면 정화가 2MB 표 한 통(1.5초)보다 2초 남짓 더 걸리는 데서 멈춘다. 넘으면 서식 없이 글자 본문을 보인다
|
||||
/// (서버의 `isHtmlBodyOmitted` 와 같은 안내 — 50단 안팎의 보통 답장 스레드는 한참 아래다)
|
||||
const int kMaxHtmlNestingCost = 8000000;
|
||||
|
||||
/// 정화한 본문 조각 — `<body>` 안에 넣는다
|
||||
class SanitizedMailBody {
|
||||
const SanitizedMailBody({
|
||||
required this.fragment,
|
||||
required this.proxiedTokens,
|
||||
this.htmlOmitted = false,
|
||||
});
|
||||
|
||||
/// 정화한 HTML 조각 — 중계 그림은 `<img src="/api/mail-images/<표>"` 꼴로 남아 있다([resolveProxiedImages] 가 바꾼다)
|
||||
final String fragment;
|
||||
|
||||
/// 받아 넣을 중계 그림의 표 — 문서 차례 · 겹치지 않게 · [kMaxProxiedImages] 까지(외부 이미지를 보일 때만)
|
||||
final List<String> proxiedTokens;
|
||||
|
||||
/// 서식 본문이 앱의 정화 한도(길이 · 중첩)를 넘어 글자 본문을 보인다 — 화면은 서버의 생략과 같은 안내를 보인다
|
||||
final bool htmlOmitted;
|
||||
}
|
||||
|
||||
/// 보여 줄 서식 본문 — null 이거나 공백뿐이면 없다(null). 그때는 textBody 를 보인다(웹 `displayedHtmlBody` · 서버와 같은 규칙)
|
||||
String? displayedHtmlBody(String? htmlBody) =>
|
||||
htmlBody != null && htmlBody.trim().isNotEmpty ? htmlBody : null;
|
||||
|
||||
/// 본문 조각을 만든다 — HTML 이 있으면 정화하고, 없으면(공백뿐 포함) 텍스트를 이스케이프한다.
|
||||
/// [imagesShown] 이면 서버의 중계 주소 그림을 남긴다(그 밖의 바깥 주소 · 상대 경로 그림은 어느 때나 지운다).
|
||||
/// 서식 본문이 길이 · 중첩 한도([kMaxSanitizedHtmlLength] · [kMaxHtmlNestingCost])를 넘으면 정화하지 않고 글자 본문을 보인다
|
||||
SanitizedMailBody mailBodyFragment({
|
||||
required String? htmlBody,
|
||||
required String? textBody,
|
||||
required bool imagesShown,
|
||||
}) {
|
||||
final String? html = displayedHtmlBody(htmlBody);
|
||||
if (html == null) return _textFragment(textBody);
|
||||
final String trimmed = html.trim();
|
||||
if (trimmed.length > kMaxSanitizedHtmlLength ||
|
||||
htmlNestingCost(trimmed, limit: kMaxHtmlNestingCost) >
|
||||
kMaxHtmlNestingCost) {
|
||||
return _textFragment(textBody, htmlOmitted: true);
|
||||
}
|
||||
return sanitizeMailHtml(trimmed, imagesShown: imagesShown);
|
||||
}
|
||||
|
||||
SanitizedMailBody _textFragment(String? textBody, {bool htmlOmitted = false}) =>
|
||||
SanitizedMailBody(
|
||||
fragment:
|
||||
'<div class="mail-text">${escapeHtmlText(textBody ?? '')}</div>',
|
||||
proxiedTokens: const <String>[],
|
||||
htmlOmitted: htmlOmitted,
|
||||
);
|
||||
|
||||
/// 정화 전 비용 — 태그(여는 · 닫는)마다 그때의 깊이를 더한다. 글자를 한 번만 훑고 [limit] 을 넘으면 그 자리에서 멈춘다.
|
||||
///
|
||||
/// 파서보다 **깊게** 센다(넉넉한 쪽 — 덜 세면 깊은 본문이 정화를 오래 붙잡는다):
|
||||
/// - 암묵 닫힘(`<p>` 뒤의 `<p>` 등)을 보지 않고, 원시 글(`<textarea>` · `<script>` 등) 안의 태그도 센다.
|
||||
/// - **주석을 건너뛰지 않는다** — 주석 안의 태그도 센다. 주석의 끝을 파서와 다르게 읽으면 그 사이를 덜 센다(서버가 주석을 지우므로 정상 메일과는 상관없다).
|
||||
/// 문서 형식 선언 · 엉뚱한 주석(`<!x …>` · `<?…>`)만 파서처럼 다음 `>` 까지 건너뛴다.
|
||||
/// - 빈 요소(`br` · `img` 등)만 깊이를 늘리지 않는다 — `<div/>` 의 `/` 는 파서가 무시하므로 여는 태그로 센다.
|
||||
/// - 닫는 태그는 맨 위에 열린 요소와 이름이 같을 때만 깊이를 줄인다 — 파서는 맞지 않는 닫는 태그(`<div>` 안의 `</span>`)를 무시하므로,
|
||||
/// 닫는 태그마다 줄이면 `<div>` 1,000 · `</span>` 1,000 · `<div>` 1,000 의 깊이를 2,000 이 아니라 1,000 으로 덜 센다.
|
||||
/// 서버가 정화한 본문은 태그를 모두 맞게 닫아 실제 깊이와 같다
|
||||
int htmlNestingCost(String html, {required int limit}) {
|
||||
final int length = html.length;
|
||||
// 열린 요소의 이름 — 깊이는 그 수다(한도를 넘으면 멈추므로 깊이도 한도의 제곱근쯤에서 그친다)
|
||||
final List<String> open = <String>[];
|
||||
int cost = 0;
|
||||
int index = 0;
|
||||
while (true) {
|
||||
index = html.indexOf('<', index);
|
||||
if (index == -1 || index + 1 >= length) break;
|
||||
final int next = html.codeUnitAt(index + 1);
|
||||
if (next == _bang || next == _question) {
|
||||
if (html.startsWith('<!--', index)) {
|
||||
index += 4;
|
||||
continue;
|
||||
}
|
||||
final int end = html.indexOf('>', index + 2);
|
||||
if (end == -1) break;
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
if (next == _slash) {
|
||||
if (index + 2 < length && _isAsciiLetter(html.codeUnitAt(index + 2))) {
|
||||
cost += open.length;
|
||||
final int nameEnd = _tagNameEnd(html, index + 2);
|
||||
if (open.isNotEmpty &&
|
||||
open.last == html.substring(index + 2, nameEnd).toLowerCase()) {
|
||||
open.removeLast();
|
||||
}
|
||||
}
|
||||
index += 2;
|
||||
} else if (_isAsciiLetter(next)) {
|
||||
final int nameEnd = _tagNameEnd(html, index + 1);
|
||||
final int close = html.indexOf('>', nameEnd);
|
||||
if (close == -1) break;
|
||||
final String name = html.substring(index + 1, nameEnd).toLowerCase();
|
||||
cost += open.length;
|
||||
if (!_htmlVoidTags.contains(name)) open.add(name);
|
||||
index = close + 1;
|
||||
} else {
|
||||
index++;
|
||||
}
|
||||
if (cost > limit) return cost;
|
||||
}
|
||||
return cost;
|
||||
}
|
||||
|
||||
/// 태그 이름이 끝나는 자리 — [start] 부터 이름 글자([_isTagNameChar])가 이어지는 데까지
|
||||
int _tagNameEnd(String html, int start) {
|
||||
int end = start;
|
||||
while (end < html.length && _isTagNameChar(html.codeUnitAt(end))) {
|
||||
end++;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
/// 화면 isolate 에서 그 자리 정화하는 서식 본문의 최대 길이(글자) — 이 길이부터는 다른 isolate 에서 정화한다.
|
||||
///
|
||||
/// 그 자리 정화가 2ms 안쪽이 되게 잡았다(2026-11-06 개발 PC · AOT 로 잰 중앙값 — 휴대폰은 몇 배 느리다):
|
||||
/// 태그가 촘촘한 평평한 본문 6,000자 1.3ms · 8,000자 1.7ms · 10,000자 2.0~2.5ms · 32,000자 7ms, 인라인 style 표 32,000자 4ms.
|
||||
/// 다른 isolate 로 보내는 값은 작은 본문 한 통에 0.8ms(결과까지의 시간 — 화면 isolate 는 그동안 비어 있다)
|
||||
const int kMailBodyInlineMaxLength = 6 * 1024;
|
||||
|
||||
/// 그 자리 정화의 최대 중첩 비용([htmlNestingCost]) — 짧아도 깊은 본문은 파서가 깊이의 제곱으로 일한다.
|
||||
///
|
||||
/// 같은 PC 에서: `div` 중첩 비용 10,000 0.6ms · 25,600 1.3ms · 40,000 1.9ms · 90,000 4.3ms · 200,000 남짓 10~14ms,
|
||||
/// 인용(`blockquote` + 문단) 중첩 20,200 1.4ms · 29,040 2.0ms. 길이 한도(6,000자 1.3ms)와 함께 2ms 안쪽이 되게 5,000 으로 둔다
|
||||
const int kMailBodyInlineMaxCost = 5000;
|
||||
|
||||
/// 화면 isolate 에서 그 자리 만드는 글자 본문(이스케이프만)의 최대 길이 — 같은 PC 에서 64,000자 1.0ms · 128,000자 2.4ms
|
||||
const int kMailBodyInlineMaxTextLength = 64 * 1024;
|
||||
|
||||
/// 본문 조각([mailBodyFragment])을 만들 곳
|
||||
enum MailBodyBuildPlace {
|
||||
/// 화면 isolate 에서 그 자리 — 짧고 얕은 본문
|
||||
inline,
|
||||
|
||||
/// 다른 isolate 에서(`Isolate.run`) — 길거나 깊은 본문
|
||||
isolate,
|
||||
}
|
||||
|
||||
/// 본문 조각을 만들 곳 — 서식 본문은 [kMailBodyInlineMaxLength] 보다 짧고 **중첩 비용도** [kMailBodyInlineMaxCost] 안일 때만 그 자리,
|
||||
/// 글자 본문은 [kMailBodyInlineMaxTextLength] 보다 짧을 때만 그 자리. 비용은 한도를 넘는 순간 멈춰 세므로 고르는 일 자체는 짧다
|
||||
MailBodyBuildPlace mailBodyBuildPlaceOf({
|
||||
required String? htmlBody,
|
||||
required String? textBody,
|
||||
}) {
|
||||
final String? html = displayedHtmlBody(htmlBody);
|
||||
if (html == null) {
|
||||
return (textBody?.length ?? 0) < kMailBodyInlineMaxTextLength
|
||||
? MailBodyBuildPlace.inline
|
||||
: MailBodyBuildPlace.isolate;
|
||||
}
|
||||
if (html.length >= kMailBodyInlineMaxLength) {
|
||||
return MailBodyBuildPlace.isolate;
|
||||
}
|
||||
return htmlNestingCost(html, limit: kMailBodyInlineMaxCost) >
|
||||
kMailBodyInlineMaxCost
|
||||
? MailBodyBuildPlace.isolate
|
||||
: MailBodyBuildPlace.inline;
|
||||
}
|
||||
|
||||
const int _bang = 0x21;
|
||||
const int _question = 0x3F;
|
||||
const int _slash = 0x2F;
|
||||
|
||||
bool _isAsciiLetter(int unit) =>
|
||||
(unit >= 0x41 && unit <= 0x5A) || (unit >= 0x61 && unit <= 0x7A);
|
||||
|
||||
/// 태그 이름의 글자 — 공백 · `/` · `>` 가 아닌 것
|
||||
bool _isTagNameChar(int unit) =>
|
||||
unit != 0x20 &&
|
||||
unit != 0x09 &&
|
||||
unit != 0x0A &&
|
||||
unit != 0x0C &&
|
||||
unit != 0x0D &&
|
||||
unit != _slash &&
|
||||
unit != 0x3E;
|
||||
|
||||
/// HTML 의 빈 요소 — 닫는 태그가 없어 깊이를 늘리지 않는다
|
||||
const Set<String> _htmlVoidTags = <String>{
|
||||
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', //
|
||||
'meta', 'source', 'track', 'wbr', 'param', 'keygen',
|
||||
};
|
||||
|
||||
/// 메일 HTML 정화 — 결과는 `<body>` 안에 넣을 조각.
|
||||
///
|
||||
/// 허용 목록 밖의 태그는 태그만 지우고 글은 남긴다(`<o:p>` · `<form>` · `<button>` 등), [_droppedWithContent] 는 내용째 버린다.
|
||||
/// HTML 이 아닌 이름공간(SVG · MathML)은 내용째 버린다. 주석은 지운다. 깊은 중첩에도 스택이 넘치지 않게 되풀이로 돈다.
|
||||
/// 문서 전체로 읽어 `<body>` 아래만 쓴다 — 조각으로 읽으면(`parseFragment`) 맨 위 요소가 많을 때 파서가 옮기며 제곱으로 느려진다
|
||||
/// (2026-11-06 측정: 맨 위 span 4만 개 10초 → 문서로 0.3초). 머리에 들어가는 태그(meta · title · style 등)는 어차피 버린다
|
||||
SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
|
||||
final dom.Element? body = html_parser.parse(html).body;
|
||||
final StringBuffer out = StringBuffer();
|
||||
final List<String> tokens = <String>[];
|
||||
final Set<String> seen = <String>{};
|
||||
if (body == null) {
|
||||
return const SanitizedMailBody(fragment: '', proxiedTokens: <String>[]);
|
||||
}
|
||||
|
||||
// 되풀이 깊이 우선 — 닫는 태그는 글로 쌓는다
|
||||
final List<Object> stack = <Object>[..._reversed(body.nodes)];
|
||||
while (stack.isNotEmpty) {
|
||||
final Object item = stack.removeLast();
|
||||
if (item is String) {
|
||||
out.write(item);
|
||||
continue;
|
||||
}
|
||||
final dom.Node node = item as dom.Node;
|
||||
if (node is dom.Text) {
|
||||
out.write(escapeHtmlText(node.data));
|
||||
continue;
|
||||
}
|
||||
if (node is! dom.Element) continue;
|
||||
final String? namespace = node.namespaceUri;
|
||||
if (namespace != null && namespace != _htmlNamespace) continue;
|
||||
final String name = (node.localName ?? '').toLowerCase();
|
||||
if (_droppedWithContent.contains(name)) continue;
|
||||
if (!_allowedTags.contains(name)) {
|
||||
stack.addAll(_reversed(node.nodes));
|
||||
continue;
|
||||
}
|
||||
out
|
||||
..write('<')
|
||||
..write(name)
|
||||
..write(
|
||||
_attributesOf(
|
||||
node,
|
||||
name,
|
||||
imagesShown: imagesShown,
|
||||
onProxied: (String token) {
|
||||
if (seen.contains(token)) return true;
|
||||
if (tokens.length >= kMaxProxiedImages) return false;
|
||||
seen.add(token);
|
||||
tokens.add(token);
|
||||
return true;
|
||||
},
|
||||
),
|
||||
)
|
||||
..write('>');
|
||||
if (_voidTags.contains(name)) continue;
|
||||
stack
|
||||
..add('</$name>')
|
||||
..addAll(_reversed(node.nodes));
|
||||
}
|
||||
return SanitizedMailBody(
|
||||
fragment: out.toString(),
|
||||
proxiedTokens: List<String>.unmodifiable(tokens),
|
||||
);
|
||||
}
|
||||
|
||||
const String _htmlNamespace = 'http://www.w3.org/1999/xhtml';
|
||||
|
||||
Iterable<dom.Node> _reversed(List<dom.Node> nodes) => nodes.reversed;
|
||||
|
||||
/// 남길 속성을 ` 이름="값"` 꼴로 — 그림은 `src` 를, 링크는 `href` 를 맨 앞에 쓴다.
|
||||
/// [onProxied] 는 남길 중계 그림이면 참(한 통의 한도를 넘으면 거짓 — 그 그림은 주소를 지운다)
|
||||
String _attributesOf(
|
||||
dom.Element element,
|
||||
String tag, {
|
||||
required bool imagesShown,
|
||||
required bool Function(String token) onProxied,
|
||||
}) {
|
||||
final Set<String> tagSpecific = _tagAttributes[tag] ?? const <String>{};
|
||||
final StringBuffer out = StringBuffer();
|
||||
final Map<String, String> attributes = <String, String>{
|
||||
for (final MapEntry<Object, String> entry in element.attributes.entries)
|
||||
if (entry.key is String) (entry.key as String).toLowerCase(): entry.value,
|
||||
};
|
||||
|
||||
// 주소 속성 — 맨 앞에
|
||||
if (tag == 'img') {
|
||||
final String? src = _imageSource(
|
||||
attributes['src'],
|
||||
imagesShown: imagesShown,
|
||||
onProxied: onProxied,
|
||||
);
|
||||
if (src != null) out.write(' src="${escapeHtmlAttribute(src)}"');
|
||||
}
|
||||
if (tag == 'a') {
|
||||
final String? href = _linkTarget(attributes['href']);
|
||||
if (href != null) out.write(' href="${escapeHtmlAttribute(href)}"');
|
||||
}
|
||||
|
||||
for (final MapEntry<String, String> entry in attributes.entries) {
|
||||
final String name = entry.key;
|
||||
if (name == 'src' || name == 'href') continue;
|
||||
if (!_globalAttributes.contains(name) && !tagSpecific.contains(name)) {
|
||||
continue;
|
||||
}
|
||||
final String value = entry.value;
|
||||
final int limit = name == 'style' ? _maxStyleLength : _maxAttributeLength;
|
||||
if (value.length > limit) continue;
|
||||
out.write(' $name="${escapeHtmlAttribute(value)}"');
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/// 그림 주소 — 늘 `data:` 그림, 외부 이미지를 보일 때만 서버의 중계 주소도. 그 밖은 null(주소를 지운다 — 그림은 숨는다)
|
||||
String? _imageSource(
|
||||
String? raw, {
|
||||
required bool imagesShown,
|
||||
required bool Function(String token) onProxied,
|
||||
}) {
|
||||
final String? src = raw?.trim();
|
||||
if (src == null || src.isEmpty) return null;
|
||||
if (_dataImage.hasMatch(src)) return src;
|
||||
if (!imagesShown) return null;
|
||||
final String? token = proxiedImageToken(src);
|
||||
if (token == null || !onProxied(token)) return null;
|
||||
return src;
|
||||
}
|
||||
|
||||
/// 링크 주소 — http · https · mailto 만, 공백 · 제어 문자가 섞인 주소는 지운다
|
||||
String? _linkTarget(String? raw) {
|
||||
final String? href = raw?.trim();
|
||||
if (href == null || href.isEmpty || href.length > _maxHrefLength) {
|
||||
return null;
|
||||
}
|
||||
if (!_safeLink.hasMatch(href)) return null;
|
||||
if (href.runes.any(_isControlOrSpace)) return null;
|
||||
return href;
|
||||
}
|
||||
|
||||
bool _isControlOrSpace(int rune) =>
|
||||
rune <= 0x20 || (rune >= 0x7F && rune <= 0x9F);
|
||||
|
||||
/// 중계 주소의 표 — 그 꼴이 아니면 null
|
||||
String? proxiedImageToken(String src) =>
|
||||
kProxiedImagePattern.firstMatch(src.trim())?.group(1);
|
||||
|
||||
/// 글 이스케이프 — 본문의 글 · 텍스트 본문
|
||||
String escapeHtmlText(String text) => text
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
|
||||
/// 속성 값 이스케이프 — 따옴표 · 꺾쇠까지(값 안에 `>` 가 남지 않아 [resolveProxiedImages] 가 태그 경계를 믿을 수 있다)
|
||||
String escapeHtmlAttribute(String value) => value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
|
||||
/// 정화한 조각의 중계 그림 주소를 받은 그림(`data:`)으로 바꾼다 — 받지 못한(실패 · 한도 밖 · 아직 받는 중) 그림은 주소를 지운다(그림이 숨는다).
|
||||
///
|
||||
/// 같은 그림이 여러 번 나오면 나온 만큼 문서에 실린다 — 그래서 실은 그림 글(`data:` 주소의 글자 수)을 **나온 차례대로** 더해
|
||||
/// [maxTotalLength] 를 넘는 자리부터는 모두 주소를 지운다(뒤의 작은 그림도). 문서를 만드는 도중에 센다 — 같은 4.9MB 그림을
|
||||
/// `<img>` 1,000번으로 넣은 본문이 6.5GB 문서가 되기 전에 멈춘다.
|
||||
/// [sanitizeMailHtml] 이 쓴 `<img src="/api/mail-images/<표>"` 꼴만 본다 — 글(이스케이프된 `<img`)과 다른 속성 값에는 닿지 않는다
|
||||
String resolveProxiedImages(
|
||||
String fragment,
|
||||
Map<String, String> dataUris, {
|
||||
int maxTotalLength = kMailImagesDocumentMaxLength,
|
||||
}) {
|
||||
int used = 0;
|
||||
bool full = false;
|
||||
return fragment.replaceAllMapped(_proxiedImageTag, (Match match) {
|
||||
final String? data = dataUris[match.group(1)!];
|
||||
if (data == null || full) return '<img';
|
||||
if (used + data.length > maxTotalLength) {
|
||||
full = true;
|
||||
return '<img';
|
||||
}
|
||||
used += data.length;
|
||||
return '<img src="${escapeHtmlAttribute(data)}"';
|
||||
});
|
||||
}
|
||||
|
||||
final RegExp _proxiedImageTag = RegExp(
|
||||
r'<img src="/api/mail-images/([A-Za-z0-9_-]+)"',
|
||||
);
|
||||
|
||||
/// 본문 문서의 기본 모양 — 앱의 메일 본문 15 · 줄 간격 1.75(앱 디자인 README "크기 · 글꼴"), 글자색 · 강조색 · 인용선은 웹 `MAIL_FRAME_STYLE` 과 같다.
|
||||
/// 글꼴은 CSP 로 내려받을 수 없어 기기 글꼴을 쓴다. 여백은 보드 `App1Reader` 의 본문(위 16 · 좌우 20 · 아래 20 — 마지막 줄이 막대에 붙지 않게 24)
|
||||
const String _documentStyle =
|
||||
'html{-webkit-text-size-adjust:100%;overflow-x:auto}'
|
||||
"body{margin:0;padding:16px 20px 24px;font-family:Pretendard,'Noto Sans KR','Apple SD Gothic Neo',system-ui,sans-serif;"
|
||||
'font-size:15px;line-height:1.75;color:#1F2733;background:#FFFFFF;overflow-wrap:anywhere}'
|
||||
'img{max-width:100%;height:auto}'
|
||||
// 주소를 지운 그림(외부 이미지 차단 · 받지 못함)은 깨진 그림 대신 숨긴다 — 차단 사실은 본문 위 안내 줄이 알린다
|
||||
'img:not([src]){display:none}'
|
||||
'a{color:#0E7C86}'
|
||||
'pre{white-space:pre-wrap}'
|
||||
'blockquote{margin:0 0 0 4px;padding-left:12px;border-left:2px solid #98A2B3;color:#4A5363}'
|
||||
'.mail-text{white-space:pre-wrap}';
|
||||
|
||||
/// WebView 에 넣을 문서 — CSP 를 맨 앞에(어떤 내용보다 먼저 걸리게) 둔다
|
||||
String buildMailDocument(String bodyFragment) => <String>[
|
||||
'<!doctype html>',
|
||||
'<html lang="ko"><head>',
|
||||
'<meta charset="utf-8">',
|
||||
'<meta http-equiv="Content-Security-Policy" content="$kMailDocumentCsp">',
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
||||
'<meta name="referrer" content="no-referrer">',
|
||||
'<style>$_documentStyle</style>',
|
||||
'</head>',
|
||||
'<body>$bodyFragment</body>',
|
||||
'</html>',
|
||||
].join();
|
||||
@@ -0,0 +1,71 @@
|
||||
/// 외부 이미지(서버의 중계 그림)를 받아 본문에 넣을 때의 한도와 판정 — 순수 함수 (계약 2절 "외부 이미지 보기").
|
||||
///
|
||||
/// WebView 는 바깥에 나가지 않으므로 앱이 Dio(Bearer)로 `GET /api/mail-images/{표}` 를 받아 `data:` 로 바꿔 넣는다.
|
||||
/// 서버가 이미 그림인지 보고(앞 바이트 — PNG · JPEG · GIF · WebP) 5MB 에서 끊지만, 앱도 받은 바이트를 다시 본다.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// 그림 하나의 최대 크기 — 서버가 바깥에서 받는 한도와 같다(5MB)
|
||||
const int kMailImageMaxBytes = 5 * 1024 * 1024;
|
||||
|
||||
/// 한 통에서 받아 두는 그림 합계의 최대 크기(서로 다른 그림마다 한 번) — 앱에서 정함. 그림은 문서 글(base64 — 4/3 배)에 실려 WebView 로 넘어가므로
|
||||
/// 메모리를 지키려고 둔다(웹은 브라우저가 그림마다 따로 받아 한도가 없다). 넘는 그림은 자리를 비운다
|
||||
const int kMailImagesTotalMaxBytes = 10 * 1024 * 1024;
|
||||
|
||||
/// 본문 문서에 싣는 그림 글(`data:` 주소의 글자 수)의 합계 한도 — 같은 그림이 여러 번 나오면 **나온 만큼** 센다.
|
||||
/// 문서 차례대로 싣다가 넘는 자리부터는 주소를 지운다(`resolveProxiedImages`). 같은 그림 하나를 `<img>` 1,000번(HTML 40KB)으로 넣어도
|
||||
/// 문서가 이 한도 안에 든다 — 받는 양의 한도([kMailImagesTotalMaxBytes])와 같은 수다
|
||||
const int kMailImagesDocumentMaxLength = 10 * 1024 * 1024;
|
||||
|
||||
/// 함께 받는 그림 수 — 서버의 한 직원 한도(6)보다 적게 둔다
|
||||
const int kMailImageConcurrency = 4;
|
||||
|
||||
/// 받은 바이트의 앞부분으로 그림 형식을 정한다 — PNG · JPEG · GIF · WebP 만(서버와 같다). 아니면 null
|
||||
String? sniffImageType(Uint8List bytes) {
|
||||
if (_startsWith(bytes, _png)) return 'image/png';
|
||||
if (_startsWith(bytes, _jpeg)) return 'image/jpeg';
|
||||
if (_startsWith(bytes, _gif87) || _startsWith(bytes, _gif89)) {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (bytes.length >= _webpHeaderLength &&
|
||||
_startsWith(bytes, _riff) &&
|
||||
_matchesAt(bytes, _webp, _webpOffset)) {
|
||||
return 'image/webp';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// `data:` 그림 주소
|
||||
String imageDataUri(String mimeType, Uint8List bytes) =>
|
||||
'data:$mimeType;base64,${base64Encode(bytes)}';
|
||||
|
||||
/// 받은 그림을 넣을 수 있는가 — 형식이 그림이고 한도 안이면 그 `data:` 주소, 아니면 null.
|
||||
/// [usedBytes] 는 이 통에 이미 넣은 그림의 바이트 합
|
||||
String? acceptMailImage(Uint8List bytes, {required int usedBytes}) {
|
||||
if (bytes.isEmpty || bytes.length > kMailImageMaxBytes) return null;
|
||||
if (usedBytes + bytes.length > kMailImagesTotalMaxBytes) return null;
|
||||
final String? type = sniffImageType(bytes);
|
||||
return type == null ? null : imageDataUri(type, bytes);
|
||||
}
|
||||
|
||||
const List<int> _png = <int>[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
const List<int> _jpeg = <int>[0xFF, 0xD8, 0xFF];
|
||||
const List<int> _gif87 = <int>[0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
|
||||
const List<int> _gif89 = <int>[0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
|
||||
const List<int> _riff = <int>[0x52, 0x49, 0x46, 0x46];
|
||||
const List<int> _webp = <int>[0x57, 0x45, 0x42, 0x50];
|
||||
const int _webpOffset = 8;
|
||||
const int _webpHeaderLength = 12;
|
||||
|
||||
bool _startsWith(Uint8List bytes, List<int> magic) =>
|
||||
_matchesAt(bytes, magic, 0);
|
||||
|
||||
bool _matchesAt(Uint8List bytes, List<int> magic, int offset) {
|
||||
if (bytes.length < offset + magic.length) return false;
|
||||
for (int index = 0; index < magic.length; index++) {
|
||||
if (bytes[offset + index] != magic[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/// 본문 WebView 의 탐색 판정 — 순수 함수 (앱 디자인 README 「읽기」: 링크는 바깥 브라우저 · mailto 는 앱의 쓰기 화면).
|
||||
///
|
||||
/// WebView 는 처음 넣은 문서(`about:blank` — `loadHtmlString` 의 기준 주소 없음) 밖으로 가지 않는다. 주 틀에서 누른 링크는 모두 막고
|
||||
/// 앱이 대신 연다 — http · https 는 바깥 앱(브라우저), mailto 는 앱의 쓰기 화면. 그 밖(javascript: · file: · content: · intent: ·
|
||||
/// 상대 주소)은 아무것도 하지 않는다. 정화가 http · https · mailto 밖의 링크를 이미 지우지만 여기서 한 번 더 본다.
|
||||
///
|
||||
/// **아래 틀(iframe)의 탐색은 이 판정을 거치지 않는다**(안드로이드 — webview_flutter_android 는 주 틀만 넘기고 아래 틀은 WebView 가 그대로 연다).
|
||||
/// 아래 틀을 막는 것은 정화(iframe · frame · object · embed 를 내용째 버림)와 문서의 CSP(`default-src 'none'` — frame-src 가 이것을 따른다)다.
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/features/mail/domain/compose_request.dart';
|
||||
|
||||
/// WebView 가 처음 연 문서의 주소 — 기준 주소 없이 넣은 문서(Android `loadDataWithBaseURL(null, …)` · iOS `loadHTMLString`)
|
||||
const String kInitialDocumentUrl = 'about:blank';
|
||||
|
||||
/// 바깥으로 보낼 링크 주소의 최대 길이 — 정화의 링크 한도와 같다
|
||||
const int kMaxExternalLinkLength = 2048;
|
||||
|
||||
/// 탐색 하나의 판정
|
||||
sealed class MailNavigation {
|
||||
const MailNavigation();
|
||||
}
|
||||
|
||||
/// 처음 넣은 문서 — 그대로 연다
|
||||
final class OpenInitialDocument extends MailNavigation {
|
||||
const OpenInitialDocument();
|
||||
}
|
||||
|
||||
/// 막고 바깥 앱(브라우저)으로 — http · https
|
||||
final class OpenExternally extends MailNavigation {
|
||||
const OpenExternally(this.uri);
|
||||
|
||||
final Uri uri;
|
||||
}
|
||||
|
||||
/// 막고 앱의 쓰기 화면으로 — mailto
|
||||
final class OpenCompose extends MailNavigation {
|
||||
const OpenCompose(this.prefill);
|
||||
|
||||
final ComposePrefill prefill;
|
||||
}
|
||||
|
||||
/// 막고 아무것도 하지 않는다
|
||||
final class IgnoreNavigation extends MailNavigation {
|
||||
const IgnoreNavigation();
|
||||
}
|
||||
|
||||
/// WebView 의 탐색 요청([url] · [isMainFrame]) → 판정
|
||||
MailNavigation mailNavigationOf(String url, {required bool isMainFrame}) {
|
||||
final String trimmed = url.trim();
|
||||
if (trimmed == kInitialDocumentUrl) {
|
||||
return isMainFrame ? const OpenInitialDocument() : const IgnoreNavigation();
|
||||
}
|
||||
// 아래 틀 — 안드로이드는 여기까지 오지 않는다(정화와 CSP 가 막는다). 다른 플랫폼이 넘기면 열지 않는다
|
||||
if (!isMainFrame) return const IgnoreNavigation();
|
||||
return mailLinkOf(trimmed);
|
||||
}
|
||||
|
||||
/// 본문의 링크 주소 → 판정 (누른 링크)
|
||||
MailNavigation mailLinkOf(String url) {
|
||||
if (url.isEmpty || url.length > kMaxExternalLinkLength) {
|
||||
return const IgnoreNavigation();
|
||||
}
|
||||
if (url.runes.any((int rune) => rune < 0x20 || rune == 0x7F)) {
|
||||
return const IgnoreNavigation();
|
||||
}
|
||||
final Uri? uri = Uri.tryParse(url);
|
||||
if (uri == null) return const IgnoreNavigation();
|
||||
switch (uri.scheme.toLowerCase()) {
|
||||
case 'http':
|
||||
case 'https':
|
||||
return uri.host.isEmpty ? const IgnoreNavigation() : OpenExternally(uri);
|
||||
case 'mailto':
|
||||
final ComposePrefill? prefill = composePrefillOfMailto(uri);
|
||||
return prefill == null ? const IgnoreNavigation() : OpenCompose(prefill);
|
||||
default:
|
||||
return const IgnoreNavigation();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:arasystem_app/core/modules/app_modules.dart';
|
||||
import 'package:arasystem_app/core/modules/module_routes.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/compose_request.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
|
||||
import 'package:arasystem_app/shared/widgets/placeholder_page.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -19,23 +21,36 @@ abstract final class MailRoutes {
|
||||
static String message(String messageId) =>
|
||||
'$home/messages/${Uri.encodeComponent(messageId)}';
|
||||
|
||||
/// 메일 쓰기 (전체 화면 — 탭 막대 없이)
|
||||
/// 메일 쓰기 (전체 화면 — 탭 막대 없이). 본문의 mailto 링크는 채울 값을 라우트의 `extra`([ComposePrefill])로 넘긴다
|
||||
static String get compose => '$home/compose';
|
||||
|
||||
/// 임시 저장본 이어 쓰기 — 임시보관함의 줄을 누르면(웹과 같다 — 주소 `?draft=<id>`)
|
||||
static String composeDraft(String draftId) => Uri(
|
||||
path: compose,
|
||||
queryParameters: <String, String>{draftQuery: draftId},
|
||||
).toString();
|
||||
static String composeDraft(String draftId) =>
|
||||
_compose(<String, String>{ComposeQuery.draft: draftId});
|
||||
|
||||
/// 답장 · 전체 답장 — 읽기 화면의 아래 막대(웹과 같다 — `?replyTo=<id>` · `&all=1`)
|
||||
static String composeReply(String messageId, {bool all = false}) =>
|
||||
_compose(<String, String>{
|
||||
ComposeQuery.replyTo: messageId,
|
||||
if (all) ComposeQuery.replyAll: ComposeQuery.replyAllValue,
|
||||
});
|
||||
|
||||
/// 전달 — 읽기 화면의 아래 막대(웹과 같다 — `?forward=<id>`)
|
||||
static String composeForward(String messageId) =>
|
||||
_compose(<String, String>{ComposeQuery.forward: messageId});
|
||||
|
||||
static String _compose(Map<String, String> query) =>
|
||||
Uri(path: compose, queryParameters: query).toString();
|
||||
|
||||
/// 이어 쓸 임시 저장본 id 의 쿼리 이름 (웹 `/mail/compose?draft=` 와 같다)
|
||||
static const String draftQuery = 'draft';
|
||||
static const String draftQuery = ComposeQuery.draft;
|
||||
}
|
||||
|
||||
/// 메일 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다.
|
||||
///
|
||||
/// 목록은 앱 보드 `App1MailList` 화면이다. 읽기 · 쓰기는 아직 자리 화면이다 — 보드(`App1Reader` · `App1Compose`)대로 만들면
|
||||
/// builder 를 `features/mail/presentation/pages/` 의 화면으로 바꾼다(경로는 그대로 둔다).
|
||||
/// 목록은 앱 보드 `App1MailList`, 읽기는 `App1Reader` 화면이다. 쓰기는 아직 자리 화면이다 — 보드(`App1Compose`)대로 만들면
|
||||
/// builder 를 `features/mail/presentation/pages/` 의 화면으로 바꾼다(경로는 그대로 — 여는 까닭은 `composeIntentOf(state.uri.queryParameters)`,
|
||||
/// mailto 로 채울 값은 `state.extra` 의 [ComposePrefill]).
|
||||
final ModuleRoutes mailModuleRoutes = ModuleRoutes(
|
||||
id: AppModuleId.mail,
|
||||
// 알림 · 깊은 링크의 id 는 메일 id 다 — 새 메일 알림("새 메일 N통")처럼 id 가 없으면 첫 화면
|
||||
@@ -53,8 +68,11 @@ final ModuleRoutes mailModuleRoutes = ModuleRoutes(
|
||||
GoRoute(
|
||||
path: 'messages/:messageId',
|
||||
parentNavigatorKey: context.rootNavigatorKey,
|
||||
builder: (BuildContext _, GoRouterState _) =>
|
||||
const PlaceholderPage(title: MailTexts.message),
|
||||
builder: (BuildContext _, GoRouterState state) => MessageReaderPage(
|
||||
// 메일마다 새 화면 — 다른 메일로 바꿔 끼우면 앞 메일의 상태(펼침 · 받기)를 이어 쓰지 않는다
|
||||
key: ValueKey<String>(state.pathParameters['messageId']!),
|
||||
messageId: state.pathParameters['messageId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'compose',
|
||||
|
||||
@@ -9,16 +9,143 @@ library;
|
||||
import 'package:arasystem_app/core/utils/korean_format.dart';
|
||||
import 'package:arasystem_app/core/utils/korean_text.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart';
|
||||
|
||||
/// 메일 모듈의 화면 이름 — 아직 자리 화면인 읽기 · 쓰기가 쓴다
|
||||
/// 메일 모듈의 화면 이름 — 아직 자리 화면인 쓰기가 쓴다
|
||||
abstract final class MailTexts {
|
||||
/// 메일 읽기 (전체 화면)
|
||||
static const String message = '메일 읽기';
|
||||
|
||||
/// 메일 쓰기 (전체 화면)
|
||||
static const String compose = '메일 쓰기';
|
||||
}
|
||||
|
||||
/// 읽기 화면 (보드 `App1Reader`). 보드에 없는 상태(붙잡힌 메일 · 본문 안내 · 받지 못함 · 첨부 열기 · 삭제 확인)의 글은
|
||||
/// 웹 상수(`frontend/src/shared/constants/mail.ts` · `mail-send-later.ts`)를 그대로 옮겼다 — 앱에서 새로 쓴 글은 그 줄에 적었다
|
||||
abstract final class MailReaderTexts {
|
||||
// ── 위 막대 ──
|
||||
static const String back = '목록으로';
|
||||
static const String delete = '삭제';
|
||||
|
||||
/// 휴지통에만 든 메일의 [delete] 대신 — 확인 뒤 영구 삭제
|
||||
static const String purge = '영구 삭제';
|
||||
static const String more = '더 보기';
|
||||
|
||||
// ── 더 보기 ──
|
||||
static const String markUnread = '안 읽음 표시';
|
||||
|
||||
/// 읽음 처리를 하지 못한 메일 — [markUnread] 대신 (앱에서 새로 씀)
|
||||
static const String markRead = '읽음 표시';
|
||||
static const String flag = '중요 표시';
|
||||
static const String unflag = '중요 해제';
|
||||
static const String move = '이동';
|
||||
static const String reportSpam = '스팸 신고';
|
||||
|
||||
/// 스팸메일함의 메일 — [reportSpam] 대신
|
||||
static const String notSpam = '스팸 아님';
|
||||
|
||||
/// 중요 표시를 바꾼 뒤 (앱에서 새로 씀 — 읽기 화면에는 별이 없어 알림으로 알린다)
|
||||
static const String flagged = '중요 표시했습니다.';
|
||||
static const String unflagged = '중요 표시를 해제했습니다.';
|
||||
|
||||
// ── 머리 ──
|
||||
static const String noSubject = '(제목 없음)';
|
||||
static const String noSender = '(보낸 사람 없음)';
|
||||
|
||||
/// 받는 사람 가운데 지금 직원
|
||||
static const String me = '나';
|
||||
|
||||
/// 받는 사람 줄 — "받는 사람 나 외 1명"
|
||||
static String recipients(String first, int others) =>
|
||||
others > 0 ? '받는 사람 $first 외 ${formatCount(others)}명' : '받는 사람 $first';
|
||||
static const String noRecipients = '받는 사람 없음';
|
||||
|
||||
// 머리 펼침의 칸 이름
|
||||
static const String fromLabel = '보낸 사람';
|
||||
static const String toLabel = '받는 사람';
|
||||
static const String ccLabel = '참조';
|
||||
static const String bccLabel = '숨은참조';
|
||||
static const String dateLabel = '날짜';
|
||||
|
||||
// ── 안내 줄 ──
|
||||
static const String imagesBlocked =
|
||||
'외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다.';
|
||||
static const String showImages = '이미지 보기';
|
||||
|
||||
/// 그림을 받아 넣는 동안 — 화면 낭독기 (앱에서 새로 씀)
|
||||
static const String imagesLoading = '이미지를 불러오는 중';
|
||||
|
||||
/// 본문 안내 (웹 `MAIL_BODY_NOTICES`)
|
||||
static String bodyNotice(MailBodyNotice notice) => switch (notice) {
|
||||
MailBodyNotice.htmlOmitted => '본문이 복잡해 서식 없이 글자만 표시합니다.',
|
||||
MailBodyNotice.unavailable => '본문이 너무 크거나 복잡해 표시할 수 없습니다.',
|
||||
MailBodyNotice.truncated => '본문이 길어 앞부분만 표시합니다.',
|
||||
};
|
||||
|
||||
/// 보내기 취소를 기다리는 메일 (앱에서 새로 씀 — 웹 읽기 화면에는 이 줄이 없다)
|
||||
static const String sendingNotice = '곧 보낼 메일입니다.';
|
||||
|
||||
/// 예약한 메일 — "11월 7일 (토) 오전 9:00 에 보낼 메일입니다." (보드 그대로 띄어 쓴다)
|
||||
static String scheduledNotice(String time) => '$time 에 보낼 메일입니다.';
|
||||
static const String undoSend = '보내기 취소';
|
||||
static const String cancelSchedule = '예약 취소';
|
||||
static const String undoSendDone = '보내기를 취소하고 임시보관함으로 옮겼습니다.';
|
||||
static const String scheduleCanceled = '예약을 취소하고 임시보관함으로 옮겼습니다.';
|
||||
|
||||
// ── 보내지 않은 메일의 [삭제] 확인 (처음 초점은 [취소]) ──
|
||||
static const String heldDeleteTitle = '보내지 않은 메일을 삭제할까요?';
|
||||
static const String heldDeleteMessage = '보내지 않고 휴지통으로 옮깁니다.';
|
||||
static const String heldDeleteConfirm = '삭제';
|
||||
static const String heldDeleteCancel = '취소';
|
||||
static const String heldDeleted = '보내지 않고 휴지통으로 옮겼습니다.';
|
||||
|
||||
// ── 본문 ──
|
||||
/// 본문 자리 — 화면 낭독기
|
||||
static const String body = '메일 본문';
|
||||
|
||||
/// 처음 불러오는 동안 — 화면 낭독기
|
||||
static const String loading = '메일 불러오는 중';
|
||||
|
||||
/// 본문을 정화해 문서로 만드는 동안 본문 자리의 진행 표시 — 화면 낭독기 (앱에서 새로 씀)
|
||||
static const String bodyLoading = '본문을 불러오고 있습니다.';
|
||||
|
||||
/// 본문의 링크를 열 앱이 없을 때 (앱에서 새로 씀)
|
||||
static const String linkFailed = '이 링크를 열 수 있는 앱이 없습니다.';
|
||||
|
||||
// ── 첨부 ──
|
||||
static String attachmentsTitle(int count) => '첨부 파일 ${formatCount(count)}개';
|
||||
|
||||
/// 받기 단추 — 화면 낭독기 "11월_정기점검_안내.pdf 받기"
|
||||
static String download(String name) => '$name 받기';
|
||||
static String cancelDownload(String name) => '$name 받기 취소';
|
||||
|
||||
/// 진행 막대 — 화면 낭독기
|
||||
static String downloading(String name) => '$name 받는 중';
|
||||
|
||||
/// 진행 글 — "받는 중 64%" (전체 크기를 모르면 "받는 중")
|
||||
static String progress(int? percent) =>
|
||||
percent == null ? '받는 중' : '받는 중 $percent%';
|
||||
|
||||
/// 대용량 첨부 줄의 크기 옆 (앱에서 새로 씀 — 첫 판은 받지 않는다)
|
||||
static const String largeFile = '대용량 첨부';
|
||||
static const String largeFilesNote =
|
||||
'대용량 첨부는 앱에서 아직 받을 수 없습니다. 본문의 링크를 누르거나 웹메일에서 받아 주세요.';
|
||||
|
||||
// 받은 파일을 열지 못했을 때 (앱에서 새로 씀)
|
||||
static const String noAppToOpen =
|
||||
'이 파일을 열 수 있는 앱이 없습니다. 파일을 여는 앱을 설치하거나 웹메일에서 받아 주세요.';
|
||||
static const String openFailed = '파일을 열지 못했습니다. 다시 받아 주세요.';
|
||||
static const String saveFailed = '파일을 저장하지 못했습니다. 휴대폰의 저장 공간을 확인해 주세요.';
|
||||
|
||||
/// 앱에서 열지 않는 형식(웹 문서 · SVG · 설치 파일 · 인증서 — `isBlockedFileOpen`)의 [받기] — 받기 전에 알린다
|
||||
static const String blockedFileType =
|
||||
'이 형식의 파일은 앱에서 열 수 없습니다. PC 웹메일에서 받아 주세요.';
|
||||
|
||||
// ── 아래 막대 ──
|
||||
/// 아래 막대 — 화면 낭독기
|
||||
static const String actions = '답장과 전달';
|
||||
static const String reply = '답장';
|
||||
static const String replyAll = '전체 답장';
|
||||
static const String forward = '전달';
|
||||
}
|
||||
|
||||
/// 목록 화면 (보드 `App1MailList` 「받은편지함」 · 「당겨서 새로고침」 · 「빈 메일함 (휴지통)」)
|
||||
abstract final class MailListTexts {
|
||||
// ── 위 막대 ──
|
||||
@@ -163,6 +290,17 @@ abstract final class MailBulkTexts {
|
||||
skipped,
|
||||
);
|
||||
|
||||
/// 스팸 아님 — "메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다." (웹 `notSpamMovedText`)
|
||||
static String notSpamMoved(
|
||||
int done,
|
||||
String mailboxName, {
|
||||
int skipped = 0,
|
||||
}) => _withSkipped(
|
||||
'메일 ${formatCount(done)}통을 스팸이 아닌 메일로 표시해 $mailboxName${directionParticle(mailboxName)} 옮겼습니다.',
|
||||
done,
|
||||
skipped,
|
||||
);
|
||||
|
||||
static String purged(int done, {int skipped = 0}) =>
|
||||
_withSkipped('메일 ${formatCount(done)}통을 영구 삭제했습니다.', done, skipped);
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/// 읽기 화면 머리의 표시 — 순수 함수 (보드 `App1Reader` 의 머리: 보낸 사람 · 시각 · "받는 사람 나 외 1명" · 펼친 칸).
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/core/utils/korean_format.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
|
||||
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/presentation/mail_texts.dart';
|
||||
|
||||
/// 머리의 시각 — 오늘 "오전 10:42" · 어제 "어제 오후 4:20" · 올해 "11월 3일 오후 4:20" · 그 전 "2025. 10. 24. 오후 4:20".
|
||||
/// 기기 시간대로 본다(목록의 `formatMailListTime` 에 시각을 붙인 꼴)
|
||||
String formatReaderTime(DateTime at, DateTime now) {
|
||||
final DateTime local = at.toLocal();
|
||||
final DateTime today = now.toLocal();
|
||||
final String time = koreanTimeOfDay(local);
|
||||
if (isSameCalendarDay(local, today)) return time;
|
||||
if (isSameCalendarDay(local, previousCalendarDay(today))) {
|
||||
return '어제 $time';
|
||||
}
|
||||
if (local.year == today.year) return '${koreanMonthDay(local)} $time';
|
||||
return '${koreanNumericDate(local)} $time';
|
||||
}
|
||||
|
||||
/// 머리 펼침의 날짜 — "2026년 11월 4일 (수) 오후 4:20"
|
||||
String formatReaderFullDate(DateTime at) => koreanFullDateTime(at.toLocal());
|
||||
|
||||
/// 받는 사람 줄 — 받는 사람 · 참조 · 숨은참조를 합쳐(같은 주소는 한 번) 지금 직원이 있으면 "나"를 앞에.
|
||||
/// "받는 사람 나" · "받는 사람 나 외 1명" · "받는 사람 박준호 외 2명" · 아무도 없으면 "받는 사람 없음"
|
||||
String recipientSummaryOf(MessageDetail detail, {required String? myEmail}) {
|
||||
final List<MailAddress> everyone = <MailAddress>[
|
||||
...detail.summary.to,
|
||||
...detail.cc,
|
||||
...detail.bcc,
|
||||
];
|
||||
final Set<String> seen = <String>{};
|
||||
final List<MailAddress> unique = <MailAddress>[
|
||||
for (final MailAddress address in everyone)
|
||||
if (seen.add(address.email.toLowerCase())) address,
|
||||
];
|
||||
if (unique.isEmpty) return MailReaderTexts.noRecipients;
|
||||
final String? me = myEmail?.toLowerCase();
|
||||
final bool includesMe = me != null && seen.contains(me);
|
||||
final String first = includesMe
|
||||
? MailReaderTexts.me
|
||||
: unique.first.displayName;
|
||||
return MailReaderTexts.recipients(first, unique.length - 1);
|
||||
}
|
||||
|
||||
/// 머리 펼침의 한 칸 — 이름 · 주소 (이름이 없으면 주소만)
|
||||
typedef ReaderAddressLine = ({String name, String? email});
|
||||
|
||||
/// 주소 하나 → 펼친 칸의 줄. 이름이 없거나 주소와 같으면 주소만 한 줄
|
||||
ReaderAddressLine readerAddressLine(MailAddress address) {
|
||||
final String? name = address.name?.trim();
|
||||
if (name == null || name.isEmpty || name == address.email) {
|
||||
return (name: address.email, email: null);
|
||||
}
|
||||
return (name: name, email: address.email);
|
||||
}
|
||||
|
||||
/// 붙잡힌 메일의 안내 줄 글 — 예약 "11월 7일 (토) 오전 9:00 에 보낼 메일입니다." · 보내기 취소를 기다리는 동안 "곧 보낼 메일입니다."
|
||||
String pendingNoticeOf(PendingSend pending) =>
|
||||
pending.kind == PendingSendKind.scheduled
|
||||
? MailReaderTexts.scheduledNotice(
|
||||
koreanScheduleTime(pending.sendAt.toLocal()),
|
||||
)
|
||||
: MailReaderTexts.sendingNotice;
|
||||
@@ -0,0 +1,506 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/api/api_exception.dart';
|
||||
import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/features/mail/data/mail_link_launcher.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
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/domain/compose_request.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_links.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mailbox_tree.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_downloads.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/message_reader_controller.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/message_reader_state.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_body_view.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_move_sheet.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/reader_attachments.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/reader_header.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/reader_notices.dart';
|
||||
import 'package:arasystem_app/shared/widgets/confirm_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// 머리(제목 · 보낸 사람 · 안내 줄 · 첨부)가 차지할 수 있는 최대 높이 — 남은 자리의 이만큼. 넘으면 머리 안에서 스크롤한다
|
||||
const double kReaderHeadMaxFraction = 0.5;
|
||||
|
||||
/// 메일 읽기 — 앱 보드 `App1Reader` (앱 디자인 README 「읽기」). 탭 막대 없는 전체 화면이다.
|
||||
///
|
||||
/// - 위 막대: 뒤로 · 삭제(휴지통에만 든 메일은 빨간 영구 삭제) · 더 보기(안 읽음 표시 · 중요 표시 · 이동 · 스팸 신고 — 스팸메일함에서는 스팸 아님).
|
||||
/// 붙잡힌 메일(보내기 취소를 기다림 · 예약)에는 이동 · 스팸이 없고 [삭제]는 확인 뒤 발송을 취소하고 휴지통으로.
|
||||
/// - 머리는 앱 화면, 본문은 WebView(`MailBodyView`) — 머리 아래 남은 자리를 다 쓰고 본문은 그 안에서 스크롤한다.
|
||||
/// 머리가 길면(펼친 받는 사람 · 첨부 여럿) 남은 자리의 절반까지만 쓰고 머리 안에서 스크롤한다.
|
||||
/// - **첨부 목록은 머리 안(안내 줄 아래 · 본문 위)이다** — 보드는 본문 아래지만, 본문이 WebView 안에서 스크롤하므로 아래에 두면
|
||||
/// 늘 화면 아래 자리를 차지한다(쓰기 화면처럼 위에 둔다 — 앱에서 정함).
|
||||
/// - 아래 행동 막대: 답장 · 전체 답장 · 전달 — 쓰기 화면(다음 차례)으로 그 메일 id 를 넘긴다(`MailRoutes.composeReply` 등). 예약한 메일에는 없다(웹과 같다).
|
||||
/// - 옮기기 · 스팸 · 휴지통 · 영구 삭제 · 발송 취소가 끝나면 목록으로 돌아간다(목록 · 메일함 개수는 컨트롤러가 맞춘다).
|
||||
/// 그 일을 하는 동안은 아래 막대와 본문 링크(mailto 포함)를 막고, 돌아가기는 읽기 화면이 맨 위일 때만 한다(위에 연 화면을 닫지 않게).
|
||||
class MessageReaderPage extends ConsumerStatefulWidget {
|
||||
const MessageReaderPage({required this.messageId, super.key});
|
||||
|
||||
/// 주소의 메일 id
|
||||
final String messageId;
|
||||
|
||||
@override
|
||||
ConsumerState<MessageReaderPage> createState() => _MessageReaderPageState();
|
||||
}
|
||||
|
||||
class _MessageReaderPageState extends ConsumerState<MessageReaderPage> {
|
||||
/// 보내지 않은 메일의 삭제 확인 창이 열려 있다 — 그사이 발송이 풀리면 닫는다(보낸 메일의 삭제 확인으로 바뀌지 않게 — 웹과 같다)
|
||||
bool _heldDialogOpen = false;
|
||||
|
||||
MessageReaderController get _controller =>
|
||||
ref.read(messageReaderProvider(widget.messageId).notifier);
|
||||
|
||||
/// 목록으로 — 쌓아 연 화면이면 뒤로, 주소로 바로 열었으면 받은편지함으로.
|
||||
/// 읽기 화면이 맨 위일 때만 닫는다 — 일이 끝나기를 기다리는 사이 위에 다른 화면이 열렸으면 그 화면을 닫지 않는다
|
||||
void _leave() {
|
||||
if (!mounted) return;
|
||||
if (!(ModalRoute.of(context)?.isCurrent ?? false)) return;
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go(MailRoutes.home);
|
||||
}
|
||||
}
|
||||
|
||||
void _notice(String text) => ref.read(mailNoticeSinkProvider)(text);
|
||||
|
||||
// ── 위 막대 ──
|
||||
|
||||
Future<void> _delete(MessageDetail detail, MailboxesState mailboxes) async {
|
||||
if (detail.summary.isHeld) {
|
||||
_heldDialogOpen = true;
|
||||
final bool confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: MailReaderTexts.heldDeleteTitle,
|
||||
message: MailReaderTexts.heldDeleteMessage,
|
||||
confirmLabel: MailReaderTexts.heldDeleteConfirm,
|
||||
cancelLabel: MailReaderTexts.heldDeleteCancel,
|
||||
danger: true,
|
||||
);
|
||||
_heldDialogOpen = false;
|
||||
if (!confirmed || !mounted) return;
|
||||
if (await _controller.deleteHeld()) _leave();
|
||||
return;
|
||||
}
|
||||
if (deletesPermanently(
|
||||
detail.summary,
|
||||
mailboxes.byRole(MailboxRole.trash),
|
||||
)) {
|
||||
final bool confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: MailBulkTexts.purgeTitle(1),
|
||||
message: MailBulkTexts.purgeMessage,
|
||||
confirmLabel: MailBulkTexts.purgeConfirm,
|
||||
cancelLabel: MailBulkTexts.purgeCancel,
|
||||
danger: true,
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
if (await _controller.purge()) _leave();
|
||||
return;
|
||||
}
|
||||
if (await _controller.moveToTrash()) _leave();
|
||||
}
|
||||
|
||||
Future<void> _onMenu(
|
||||
ReaderMenuAction action,
|
||||
MessageDetail detail,
|
||||
MailboxesState mailboxes,
|
||||
) async {
|
||||
switch (action) {
|
||||
case ReaderMenuAction.toggleRead:
|
||||
final bool markUnread = detail.summary.isRead;
|
||||
// [안 읽음 표시]는 나중에 읽을 메일이라 목록으로 돌아간다(흔한 메일 앱과 같다 — 앱에서 정함)
|
||||
if (await _controller.setRead(!markUnread) && markUnread) _leave();
|
||||
case ReaderMenuAction.toggleFlag:
|
||||
await _controller.toggleFlag();
|
||||
case ReaderMenuAction.move:
|
||||
final List<MailboxNode> targets = <MailboxNode>[
|
||||
for (final MailboxNode node in moveTargetsOf(mailboxes.items, null))
|
||||
if (!detail.summary.mailboxIds.contains(node.mailbox.id)) node,
|
||||
];
|
||||
if (targets.isEmpty) return;
|
||||
final Mailbox? chosen = await showMailboxPickerSheet(
|
||||
context,
|
||||
targets: targets,
|
||||
);
|
||||
if (chosen == null || !mounted) return;
|
||||
if (await _controller.moveToMailbox(chosen)) _leave();
|
||||
case ReaderMenuAction.toggleSpam:
|
||||
if (await _controller.toggleSpam()) _leave();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 안내 줄 ──
|
||||
|
||||
Future<void> _cancelHeld() async {
|
||||
if (await _controller.cancelHeld()) _leave();
|
||||
}
|
||||
|
||||
// ── 본문 링크 ──
|
||||
|
||||
void _onNavigation(MailNavigation navigation) {
|
||||
// 삭제 · 이동 · 스팸 등을 하는 동안은 열지 않는다 — 끝나면 목록으로 돌아간다
|
||||
if (ref.read(messageReaderProvider(widget.messageId)).busy != null) return;
|
||||
switch (navigation) {
|
||||
case OpenExternally(:final Uri uri):
|
||||
unawaited(_openExternally(uri));
|
||||
case OpenCompose(:final ComposePrefill prefill):
|
||||
unawaited(context.push<void>(MailRoutes.compose, extra: prefill));
|
||||
case OpenInitialDocument():
|
||||
case IgnoreNavigation():
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openExternally(Uri uri) async {
|
||||
final bool opened = await ref.read(mailLinkLauncherProvider)(uri);
|
||||
if (!opened && mounted) _notice(MailReaderTexts.linkFailed);
|
||||
}
|
||||
|
||||
// ── 아래 막대 ──
|
||||
|
||||
void _compose(String location) => unawaited(context.push<void>(location));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final MessageReaderState reader = ref.watch(
|
||||
messageReaderProvider(widget.messageId),
|
||||
);
|
||||
final MailboxesState mailboxes = ref.watch(mailboxesProvider);
|
||||
// 받은편지함 위에서 연 화면이 사라질 때까지 [되돌리기] 알림을 쥔다(컨트롤러도 쥔다)
|
||||
ref.listen<MailToast?>(mailToastProvider, (MailToast? _, MailToast? _) {});
|
||||
ref.listen<MessageReaderState>(messageReaderProvider(widget.messageId), (
|
||||
MessageReaderState? previous,
|
||||
MessageReaderState next,
|
||||
) {
|
||||
// 확인 창을 연 채 발송이 풀렸다 — 창을 닫는다
|
||||
if (_heldDialogOpen && (previous?.isHeld ?? false) && !next.isHeld) {
|
||||
_heldDialogOpen = false;
|
||||
Navigator.of(context, rootNavigator: true).pop(false);
|
||||
}
|
||||
});
|
||||
|
||||
final MessageDetail? detail = reader.status == MessageReaderStatus.ready
|
||||
? reader.detail
|
||||
: null;
|
||||
final bool busy = reader.busy != null;
|
||||
final bool held = detail?.summary.isHeld ?? false;
|
||||
final bool scheduled =
|
||||
detail?.summary.pendingSend?.kind == PendingSendKind.scheduled;
|
||||
|
||||
return Scaffold(
|
||||
appBar: ReaderAppBar(
|
||||
onBack: _leave,
|
||||
onDelete: detail == null || busy
|
||||
? null
|
||||
: () => unawaited(_delete(detail, mailboxes)),
|
||||
permanentDelete:
|
||||
detail != null &&
|
||||
!held &&
|
||||
deletesPermanently(
|
||||
detail.summary,
|
||||
mailboxes.byRole(MailboxRole.trash),
|
||||
),
|
||||
menu: detail == null
|
||||
? const <ReaderMenuItem>[]
|
||||
: readerMenuOf(detail, mailboxes),
|
||||
onMenu: detail == null || busy
|
||||
? null
|
||||
: (ReaderMenuAction action) =>
|
||||
unawaited(_onMenu(action, detail, mailboxes)),
|
||||
),
|
||||
body: switch (reader.status) {
|
||||
MessageReaderStatus.loading => const _ReaderSkeleton(),
|
||||
MessageReaderStatus.failed => _ReaderError(
|
||||
error: reader.error ?? ApiException.fromCode('SYS_001'),
|
||||
onRetry: () => unawaited(_controller.retry()),
|
||||
onLeave: _leave,
|
||||
),
|
||||
MessageReaderStatus.ready => _ReaderContent(
|
||||
messageId: widget.messageId,
|
||||
reader: reader,
|
||||
onShowImages: () => unawaited(_controller.showImages()),
|
||||
onCancelHeld: () => unawaited(_cancelHeld()),
|
||||
onNavigation: _onNavigation,
|
||||
),
|
||||
},
|
||||
// 삭제 · 이동 · 스팸 등을 하는 동안은 쓰기 화면을 열지 않는다 — 끝나면 목록으로 돌아간다
|
||||
bottomNavigationBar: detail == null || scheduled
|
||||
? null
|
||||
: ReaderActionBar(
|
||||
onReply: busy
|
||||
? null
|
||||
: () => _compose(MailRoutes.composeReply(widget.messageId)),
|
||||
onReplyAll: busy
|
||||
? null
|
||||
: () => _compose(
|
||||
MailRoutes.composeReply(widget.messageId, all: true),
|
||||
),
|
||||
onForward: busy
|
||||
? null
|
||||
: () => _compose(MailRoutes.composeForward(widget.messageId)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 휴지통에만 든 메일인가 — 그러면 [삭제]가 확인 뒤 영구 삭제다(휴지통과 다른 메일함에 함께 든 메일은 휴지통으로 옮기기 — 웹과 같다).
|
||||
/// 휴지통을 아직 모르면 아니라고 본다(옮기기 쪽이 해롭지 않다)
|
||||
bool deletesPermanently(MessageSummary summary, Mailbox? trash) {
|
||||
if (trash == null || summary.mailboxIds.isEmpty) return false;
|
||||
return summary.mailboxIds.every((String id) => id == trash.id);
|
||||
}
|
||||
|
||||
/// 더 보기 메뉴 — 읽음 · 중요는 늘, 이동 · 스팸은 붙잡히지 않은 메일이고 옮길 곳을 알 때만
|
||||
List<ReaderMenuItem> readerMenuOf(
|
||||
MessageDetail detail,
|
||||
MailboxesState mailboxes,
|
||||
) {
|
||||
final MessageSummary summary = detail.summary;
|
||||
final Mailbox? junk = mailboxes.byRole(MailboxRole.junk);
|
||||
final bool inJunk = junk != null && summary.mailboxIds.contains(junk.id);
|
||||
final bool spamTarget = inJunk
|
||||
? mailboxes.byRole(MailboxRole.inbox) != null
|
||||
: junk != null;
|
||||
final bool canMove = moveTargetsOf(
|
||||
mailboxes.items,
|
||||
null,
|
||||
).any((MailboxNode node) => !summary.mailboxIds.contains(node.mailbox.id));
|
||||
return <ReaderMenuItem>[
|
||||
summary.isRead
|
||||
? const ReaderMenuItem(
|
||||
ReaderMenuAction.toggleRead,
|
||||
MailReaderTexts.markUnread,
|
||||
Icons.mark_email_unread_outlined,
|
||||
)
|
||||
: const ReaderMenuItem(
|
||||
ReaderMenuAction.toggleRead,
|
||||
MailReaderTexts.markRead,
|
||||
Icons.drafts_outlined,
|
||||
),
|
||||
summary.isFlagged
|
||||
? const ReaderMenuItem(
|
||||
ReaderMenuAction.toggleFlag,
|
||||
MailReaderTexts.unflag,
|
||||
Icons.star,
|
||||
)
|
||||
: const ReaderMenuItem(
|
||||
ReaderMenuAction.toggleFlag,
|
||||
MailReaderTexts.flag,
|
||||
Icons.star_border,
|
||||
),
|
||||
if (!summary.isHeld && canMove)
|
||||
const ReaderMenuItem(
|
||||
ReaderMenuAction.move,
|
||||
MailReaderTexts.move,
|
||||
Icons.drive_file_move_outlined,
|
||||
),
|
||||
if (!summary.isHeld && spamTarget)
|
||||
inJunk
|
||||
? const ReaderMenuItem(
|
||||
ReaderMenuAction.toggleSpam,
|
||||
MailReaderTexts.notSpam,
|
||||
Icons.inbox_outlined,
|
||||
)
|
||||
: const ReaderMenuItem(
|
||||
ReaderMenuAction.toggleSpam,
|
||||
MailReaderTexts.reportSpam,
|
||||
Icons.report_outlined,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 받은 메일 — 머리(남은 자리의 절반까지 · 넘으면 그 안에서 스크롤) + 본문 WebView(남은 자리 전부)
|
||||
class _ReaderContent extends ConsumerWidget {
|
||||
const _ReaderContent({
|
||||
required this.messageId,
|
||||
required this.reader,
|
||||
required this.onShowImages,
|
||||
required this.onCancelHeld,
|
||||
required this.onNavigation,
|
||||
});
|
||||
|
||||
final String messageId;
|
||||
final MessageReaderState reader;
|
||||
final VoidCallback onShowImages;
|
||||
final VoidCallback onCancelHeld;
|
||||
final void Function(MailNavigation navigation) onNavigation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final MessageDetail detail = reader.detail!;
|
||||
final Map<String, AttachmentDownload> downloads = ref.watch(
|
||||
attachmentDownloadsProvider(messageId),
|
||||
);
|
||||
final AttachmentDownloadsController downloader = ref.read(
|
||||
attachmentDownloadsProvider(messageId).notifier,
|
||||
);
|
||||
// 시각 표기의 "오늘 · 어제"를 가르는 지금 — 시험은 손 시계로 덮어쓴다
|
||||
final DateTime now = ref.read(clockProvider)();
|
||||
final String? myEmail = ref.watch(mailAccountProvider);
|
||||
final List<MessageAttachment> attachments = detail.listedAttachments;
|
||||
final String? document = reader.document;
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: constraints.maxHeight * kReaderHeadMaxFraction,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
ReaderHeader(detail: detail, myEmail: myEmail, now: now),
|
||||
ReaderNotices(
|
||||
detail: detail,
|
||||
htmlOmitted: reader.htmlOmitted,
|
||||
busy: reader.busy != null,
|
||||
imagesLoading: reader.imagesLoading,
|
||||
onShowImages: onShowImages,
|
||||
onCancelHeld: onCancelHeld,
|
||||
),
|
||||
if (attachments.isNotEmpty || detail.largeFiles.isNotEmpty)
|
||||
ReaderAttachments(
|
||||
attachments: attachments,
|
||||
largeFiles: detail.largeFiles,
|
||||
downloads: downloads,
|
||||
onDownload: (MessageAttachment attachment) =>
|
||||
unawaited(downloader.download(attachment)),
|
||||
onCancel: (MessageAttachment attachment) =>
|
||||
downloader.cancel(attachment.id),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: AppSpacing.s4),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (reader.imagesLoading)
|
||||
Semantics(
|
||||
label: MailReaderTexts.imagesLoading,
|
||||
liveRegion: true,
|
||||
child: const LinearProgressIndicator(
|
||||
minHeight: AppSize.progressBarHeight,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: document == null
|
||||
? const _BodyLoading()
|
||||
: MailBodyView(
|
||||
document: document,
|
||||
onNavigation: onNavigation,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 본문을 정화해 문서로 만드는 동안(큰 본문은 다른 isolate 에서) — 본문 자리 맨 위의 가는 진행 줄
|
||||
class _BodyLoading extends StatelessWidget {
|
||||
const _BodyLoading();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
label: MailReaderTexts.bodyLoading,
|
||||
liveRegion: true,
|
||||
child: const Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: LinearProgressIndicator(minHeight: AppSize.progressBarHeight),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 처음 받는 동안 — 제목 · 보낸 사람 · 본문 줄 모양의 회색 막대
|
||||
class _ReaderSkeleton extends StatelessWidget {
|
||||
const _ReaderSkeleton();
|
||||
|
||||
static const List<double> _widths = <double>[0.7, 0.45, 1, 1, 0.6];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color bar = Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
return Semantics(
|
||||
label: MailReaderTexts.loading,
|
||||
liveRegion: true,
|
||||
child: ExcludeSemantics(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s20,
|
||||
AppSpacing.s12,
|
||||
AppSpacing.s20,
|
||||
0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
for (final double width in _widths) ...<Widget>[
|
||||
FractionallySizedBox(
|
||||
widthFactor: width,
|
||||
child: Container(
|
||||
height: AppSpacing.s12,
|
||||
decoration: BoxDecoration(
|
||||
color: bar,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xs),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 받지 못함 — 사전 문구 · 문의 코드 · [다시 시도](없는 메일은 [목록으로] — 다시 해도 같다)
|
||||
class _ReaderError extends StatelessWidget {
|
||||
const _ReaderError({
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
required this.onLeave,
|
||||
});
|
||||
|
||||
final ApiException error;
|
||||
final VoidCallback onRetry;
|
||||
final VoidCallback onLeave;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: MailListError(
|
||||
error: error,
|
||||
onRetry: onRetry,
|
||||
onLeave: onLeave,
|
||||
leaveLabel: MailReaderTexts.back,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
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/downloaded_files.dart';
|
||||
import 'package:arasystem_app/core/files/file_types.dart';
|
||||
import 'package:arasystem_app/core/storage/token_store.dart';
|
||||
import 'package:arasystem_app/core/storage/token_store_provider.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:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
|
||||
/// 받고 있는 첨부 하나
|
||||
@immutable
|
||||
class AttachmentDownload {
|
||||
const AttachmentDownload({required this.received, this.total});
|
||||
|
||||
/// 받은 바이트
|
||||
final int received;
|
||||
|
||||
/// 전체 바이트 — 모르면 null
|
||||
final int? total;
|
||||
|
||||
/// 받은 비율(0 ~ 100) — 전체를 모르면 null
|
||||
int? get percent {
|
||||
final int? all = total;
|
||||
if (all == null || all <= 0) return null;
|
||||
return (received * 100 ~/ all).clamp(0, 100);
|
||||
}
|
||||
|
||||
/// 진행 막대의 값(0 ~ 1) — 전체를 모르면 null(돌기만 한다)
|
||||
double? get fraction {
|
||||
final int? value = percent;
|
||||
return value == null ? null : value / 100;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is AttachmentDownload &&
|
||||
other.received == received &&
|
||||
other.total == total;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(received, total);
|
||||
}
|
||||
|
||||
/// 이 세션에 받아 둔 첨부 — 같은 첨부를 다시 누르면 받지 않고 바로 연다. 메일을 보는 직원이 바뀌면 받은 파일을 모두 지운다.
|
||||
///
|
||||
/// 앱이 살아 있는 동안 둔다(받은 파일 기록). 지난 실행이 남긴 파일은 main 이 앱을 띄울 때 지운다(`DownloadedFileStore.purge`)
|
||||
final Provider<MailDownloadedFiles> mailDownloadedFilesProvider =
|
||||
Provider<MailDownloadedFiles>((Ref ref) {
|
||||
final MailDownloadedFiles files = MailDownloadedFiles(
|
||||
ref.watch(downloadedFileStoreProvider),
|
||||
);
|
||||
ref.listen<String?>(mailAccountProvider, (
|
||||
String? previous,
|
||||
String? next,
|
||||
) {
|
||||
if (previous != next) unawaited(files.forgetAll());
|
||||
});
|
||||
return files;
|
||||
});
|
||||
|
||||
/// 받아 둔 첨부의 기록 — (메일 id, 첨부 id) → 기기 안 경로
|
||||
class MailDownloadedFiles {
|
||||
MailDownloadedFiles(this.store);
|
||||
|
||||
final DownloadedFileStore store;
|
||||
final Map<String, String> _paths = <String, String>{};
|
||||
|
||||
static String _key(String messageId, String attachmentId) =>
|
||||
'$messageId\n$attachmentId';
|
||||
|
||||
String? pathOf(String messageId, String attachmentId) =>
|
||||
_paths[_key(messageId, attachmentId)];
|
||||
|
||||
void remember(String messageId, String attachmentId, String path) =>
|
||||
_paths[_key(messageId, attachmentId)] = path;
|
||||
|
||||
void forget(String messageId, String attachmentId) =>
|
||||
_paths.remove(_key(messageId, attachmentId));
|
||||
|
||||
/// 기록을 비우고 받은 파일을 모두 지운다 — 로그아웃 · 세션 끝 · 다른 직원
|
||||
Future<void> forgetAll() {
|
||||
_paths.clear();
|
||||
return store.purge();
|
||||
}
|
||||
}
|
||||
|
||||
/// 한 메일의 첨부 받기 — 메일 id 마다 하나, 읽기 화면이 있는 동안만(autoDispose — 떠나면 받던 것을 그만둔다)
|
||||
final NotifierProviderFamily<
|
||||
AttachmentDownloadsController,
|
||||
Map<String, AttachmentDownload>,
|
||||
String
|
||||
>
|
||||
attachmentDownloadsProvider = NotifierProvider.autoDispose
|
||||
.family<
|
||||
AttachmentDownloadsController,
|
||||
Map<String, AttachmentDownload>,
|
||||
String
|
||||
>(AttachmentDownloadsController.new);
|
||||
|
||||
/// 첨부 받기 — 계약 2절 `GET /api/messages/{id}/attachments/{attachmentId}` (보드 `App1Reader` 「첨부 받는 중」).
|
||||
///
|
||||
/// - [받기]를 누르면 앱 임시 폴더에 받고(진행 %) 다 받으면 다른 앱으로 연다(`FileOpener` — 안드로이드 `ACTION_VIEW`).
|
||||
/// 이 세션에 받아 둔 파일이 있으면 다시 받지 않고 연다. 열 형식은 확장자에서 먼저 정한다(`fileOpenTypeOf` — 보낸 쪽 형식은 확장자를 모를 때만).
|
||||
/// - 웹 문서 · SVG · 설치 파일 · 인증서 · 키 묶음은 받지 않고 알린다(`isBlockedFileOpen`) — 다른 앱(브라우저)이 스크립트를 돌리지 않게.
|
||||
/// - [받기 취소]는 받던 것을 그만두고 쓰다 만 파일을 지운다. 화면을 떠나도 그만둔다.
|
||||
/// - 서버의 거절(없음 등)은 인터셉터가 알렸다. 받는 도중 끊기면 여기서 `SYS_001` 로 알린다(응답이 이미 인터셉터를 지났다).
|
||||
/// - 401 은 인증 인터셉터가 갱신한 뒤 다시 보낸다. 세션이 바뀌었으면 받은 파일을 지우고 열지 않는다.
|
||||
class AttachmentDownloadsController
|
||||
extends Notifier<Map<String, AttachmentDownload>> {
|
||||
AttachmentDownloadsController(this.messageId);
|
||||
|
||||
final String messageId;
|
||||
final Map<String, CancelToken> _cancels = <String, CancelToken>{};
|
||||
|
||||
@override
|
||||
Map<String, AttachmentDownload> build() {
|
||||
ref.watch(mailAccountProvider);
|
||||
_cancelAll();
|
||||
ref.onDispose(_cancelAll);
|
||||
return const <String, AttachmentDownload>{};
|
||||
}
|
||||
|
||||
void _cancelAll() {
|
||||
for (final CancelToken token in _cancels.values) {
|
||||
token.cancel();
|
||||
}
|
||||
_cancels.clear();
|
||||
}
|
||||
|
||||
/// [받기] — 받는 중이면 아무것도 하지 않는다. 앱에서 열지 않는 형식(`isBlockedFileOpen` — 웹 문서 · SVG · 설치 파일 · 인증서)은
|
||||
/// 받기 전에 알리고 받지 않는다
|
||||
Future<void> download(MessageAttachment attachment) async {
|
||||
final Ref ref = this.ref;
|
||||
if (!ref.mounted || state.containsKey(attachment.id)) return;
|
||||
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
|
||||
if (isBlockedFileOpen(attachment.name, attachment.type)) {
|
||||
notice(MailReaderTexts.blockedFileType);
|
||||
return;
|
||||
}
|
||||
final MailDownloadedFiles files = ref.read(mailDownloadedFilesProvider);
|
||||
final MailRepository repository = ref.read(mailRepositoryProvider);
|
||||
final FileOpener opener = ref.read(fileOpenerProvider);
|
||||
final ErrorNotifier errors = ref.read(errorNotifierProvider);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
|
||||
final String? saved = files.pathOf(messageId, attachment.id);
|
||||
if (saved != null) {
|
||||
if (await files.store.exists(saved)) {
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
await _open(
|
||||
saved,
|
||||
attachment,
|
||||
opener: opener,
|
||||
files: files,
|
||||
notice: notice,
|
||||
);
|
||||
return;
|
||||
}
|
||||
files.forget(messageId, attachment.id);
|
||||
}
|
||||
|
||||
final CancelToken cancel = CancelToken();
|
||||
_cancels[attachment.id] = cancel;
|
||||
_setProgress(attachment.id, 0, _sizeOf(attachment));
|
||||
DownloadTarget? target;
|
||||
try {
|
||||
target = await files.store.create(attachment.name);
|
||||
await repository.downloadAttachment(
|
||||
messageId: messageId,
|
||||
attachmentId: attachment.id,
|
||||
target: target,
|
||||
cancelToken: cancel,
|
||||
onProgress: (int received, int? total) {
|
||||
if (ref.mounted && !cancel.isCancelled) {
|
||||
_setProgress(attachment.id, received, total ?? _sizeOf(attachment));
|
||||
}
|
||||
},
|
||||
);
|
||||
await target.close();
|
||||
} on DioException catch (error) {
|
||||
await target?.discard();
|
||||
_finish(attachment.id, cancel);
|
||||
if (error.type == DioExceptionType.cancel) return;
|
||||
// 받는 도중 끊겼다 — 응답은 인터셉터를 지나 아무도 알리지 않았다
|
||||
if (error is DownloadInterrupted && tokens.sessionGeneration == session) {
|
||||
errors.show(ApiException.fromCode('SYS_001'));
|
||||
}
|
||||
return;
|
||||
} on FileSystemException {
|
||||
await target?.discard();
|
||||
_finish(attachment.id, cancel);
|
||||
if (tokens.sessionGeneration == session) {
|
||||
notice(MailReaderTexts.saveFailed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_finish(attachment.id, cancel);
|
||||
// 그사이 세션이 끝났거나 다른 직원이 들어왔다 — 받은 파일을 남기지 않는다
|
||||
if (tokens.sessionGeneration != session) {
|
||||
await target.discard();
|
||||
return;
|
||||
}
|
||||
files.remember(messageId, attachment.id, target.path);
|
||||
// 화면을 떠났으면 열지 않는다 — 받은 파일은 다음에 누르면 바로 연다
|
||||
if (!ref.mounted || cancel.isCancelled) return;
|
||||
await _open(
|
||||
target.path,
|
||||
attachment,
|
||||
opener: opener,
|
||||
files: files,
|
||||
notice: notice,
|
||||
);
|
||||
}
|
||||
|
||||
/// [받기 취소]
|
||||
void cancel(String attachmentId) {
|
||||
if (!ref.mounted) return;
|
||||
_cancels.remove(attachmentId)?.cancel();
|
||||
if (!state.containsKey(attachmentId)) return;
|
||||
state = <String, AttachmentDownload>{...state}..remove(attachmentId);
|
||||
}
|
||||
|
||||
Future<void> _open(
|
||||
String path,
|
||||
MessageAttachment attachment, {
|
||||
required FileOpener opener,
|
||||
required MailDownloadedFiles files,
|
||||
required void Function(String text) notice,
|
||||
}) async {
|
||||
// 열 형식은 확장자에서 먼저 — 보낸 쪽이 정한 형식은 확장자를 모를 때만(`fileOpenTypeOf`)
|
||||
final FileOpenResult result = await opener.open(
|
||||
path: path,
|
||||
mimeType: fileOpenTypeOf(attachment.name, attachment.type),
|
||||
);
|
||||
switch (result) {
|
||||
case FileOpenResult.opened:
|
||||
return;
|
||||
case FileOpenResult.noApp:
|
||||
notice(MailReaderTexts.noAppToOpen);
|
||||
case FileOpenResult.notFound:
|
||||
case FileOpenResult.failed:
|
||||
files.forget(messageId, attachment.id);
|
||||
notice(MailReaderTexts.openFailed);
|
||||
}
|
||||
}
|
||||
|
||||
int? _sizeOf(MessageAttachment attachment) =>
|
||||
attachment.size > 0 ? attachment.size : null;
|
||||
|
||||
/// 진행을 적는다 — 보이는 비율이 바뀔 때만(조각마다 화면을 다시 그리지 않게)
|
||||
void _setProgress(String attachmentId, int received, int? total) {
|
||||
final AttachmentDownload next = AttachmentDownload(
|
||||
received: received,
|
||||
total: total,
|
||||
);
|
||||
final AttachmentDownload? current = state[attachmentId];
|
||||
if (current != null &&
|
||||
current.total == next.total &&
|
||||
current.percent == next.percent &&
|
||||
next.percent != null) {
|
||||
return;
|
||||
}
|
||||
state = <String, AttachmentDownload>{...state, attachmentId: next};
|
||||
}
|
||||
|
||||
/// 받기 하나가 끝났다 — 그 받기([cancel])가 아직 이 첨부의 받기일 때만 진행을 지운다.
|
||||
/// [받기 취소] 바로 뒤에 다시 [받기]를 눌렀으면 앞 받기의 끝(취소)이 새 받기의 진행을 지우지 않는다
|
||||
void _finish(String attachmentId, CancelToken cancel) {
|
||||
if (!identical(_cancels[attachmentId], cancel)) return;
|
||||
_cancels.remove(attachmentId);
|
||||
if (!ref.mounted || !state.containsKey(attachmentId)) return;
|
||||
state = <String, AttachmentDownload>{...state}..remove(attachmentId);
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,7 @@ class MailListController extends Notifier<MailListState> {
|
||||
|
||||
void _onMailChange(MailChange change) {
|
||||
if (identical(change.source, this)) return;
|
||||
_applyLocalChanges(change);
|
||||
if (_visible) {
|
||||
unawaited(_sync());
|
||||
} else {
|
||||
@@ -324,6 +325,44 @@ class MailListController extends Notifier<MailListState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 읽기 화면이 바꾼 메일을 서버에 다시 묻기 전에 바로 바꾼다 — 읽음 · 중요는 그 줄을 바꾸고,
|
||||
/// 이 메일함을 떠난 메일(옮기기 · 스팸 · 휴지통)과 뺀 메일(영구 삭제 · 발송 취소)은 뺀다. 그 전에 떠난 맞추기 응답은 버린다
|
||||
void _applyLocalChanges(MailChange change) {
|
||||
if (!change.hasLocalChanges || state.status != MailListStatus.ready) return;
|
||||
final String? mailboxId = state.mailboxId;
|
||||
final Map<String, MessageSummary> updated = <String, MessageSummary>{
|
||||
for (final MessageSummary message in change.updated) message.id: message,
|
||||
};
|
||||
bool changed = false;
|
||||
final List<MessageSummary> kept = <MessageSummary>[];
|
||||
for (final MessageSummary message in state.items) {
|
||||
if (change.removedIds.contains(message.id)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
final MessageSummary? next = updated[message.id];
|
||||
if (next == null) {
|
||||
kept.add(message);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
if (mailboxId != null && !next.mailboxIds.contains(mailboxId)) continue;
|
||||
kept.add(next);
|
||||
}
|
||||
if (!changed) return;
|
||||
final int removed = state.items.length - kept.length;
|
||||
final MailPageInfo? info = state.pageInfo;
|
||||
final int? total = info?.total;
|
||||
_localGeneration++;
|
||||
state = state.withItems(
|
||||
_withPendingFlags(kept),
|
||||
pageInfo: info != null && total != null && removed > 0
|
||||
? info.copyWith(total: math.max(0, total - removed))
|
||||
: info,
|
||||
);
|
||||
_afterItemsChanged();
|
||||
}
|
||||
|
||||
/// 보이는 목록을 지금까지 불러온 만큼(최대 100통) 다시 받아 맞춘다 — 고른 메일 · 스크롤 위치는 그대로(계약 2절 "새 메일 확인").
|
||||
/// 한 번에 하나 — 도는 동안 또 부르면 끝난 뒤 한 번 더 맞추고, 돌려주는 Future 는 그것까지 끝나야 끝난다.
|
||||
/// 실패는 알리지 않는다(다음 확인에서 다시 맞춘다)
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
import 'package:arasystem_app/core/utils/app_foreground.dart';
|
||||
import 'package:arasystem_app/core/utils/clock.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -17,7 +18,12 @@ const Duration kMailStateRecheckGap = Duration(seconds: 10);
|
||||
/// 메일이 바뀌었다는 소식 — 목록 · 메일함 개수가 듣는다
|
||||
@immutable
|
||||
class MailChange {
|
||||
const MailChange(this.seq, {this.source});
|
||||
const MailChange(
|
||||
this.seq, {
|
||||
this.source,
|
||||
this.updated = const <MessageSummary>[],
|
||||
this.removedIds = const <String>{},
|
||||
});
|
||||
|
||||
/// 소식마다 하나씩 오른다 — 처음부터 다시 만들면 0
|
||||
final int seq;
|
||||
@@ -25,14 +31,31 @@ class MailChange {
|
||||
/// 이 앱의 목록이 스스로 바꾼 것이면 그 목록(그 목록은 이미 맞췄다) — 서버에서 알아챈 바뀜 · 되돌리기는 null
|
||||
final Object? source;
|
||||
|
||||
/// 읽기 화면이 바꾼 메일의 요약(`PATCH /api/messages/{id}` 의 응답에서 그 요청이 바꾼 칸만 읽기 화면의 요약에 덮은 것) — 목록은 서버에 다시 묻기 전에 그 줄을 바로 바꾼다
|
||||
/// (그 메일함을 떠났으면 뺀다). 맞추기는 그대로 한다
|
||||
final List<MessageSummary> updated;
|
||||
|
||||
/// 읽기 화면이 영구 삭제 · 발송 취소로 뺀 메일 — 목록은 바로 뺀다
|
||||
final Set<String> removedIds;
|
||||
|
||||
/// 목록이 바로 바꿀 것이 있다
|
||||
bool get hasLocalChanges => updated.isNotEmpty || removedIds.isNotEmpty;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is MailChange &&
|
||||
other.seq == seq &&
|
||||
identical(other.source, source);
|
||||
identical(other.source, source) &&
|
||||
listEquals(other.updated, updated) &&
|
||||
setEquals(other.removedIds, removedIds);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(seq, identityHashCode(source));
|
||||
int get hashCode => Object.hash(
|
||||
seq,
|
||||
identityHashCode(source),
|
||||
Object.hashAll(updated),
|
||||
Object.hashAllUnordered(removedIds),
|
||||
);
|
||||
}
|
||||
|
||||
/// 새 메일 확인 — 메일 목록이 보이는 동안만 돈다. 세션이 바뀌면 기준을 버린다.
|
||||
@@ -104,10 +127,20 @@ class MailStateWatcher extends Notifier<MailChange> {
|
||||
if (before != _active) _onActiveChanged();
|
||||
}
|
||||
|
||||
/// 이 앱이 메일을 바꿨다 — 다른 목록 · 메일함 개수가 맞추게 알린다. [source] 는 이미 맞춘 목록(없으면 모든 목록)
|
||||
void markChanged({Object? source}) {
|
||||
/// 이 앱이 메일을 바꿨다 — 다른 목록 · 메일함 개수가 맞추게 알린다. [source] 는 이미 맞춘 목록(없으면 모든 목록).
|
||||
/// 읽기 화면은 바꾼 메일의 요약([updated]) · 뺀 메일([removedIds])을 실어 목록이 바로 바꾸게 한다
|
||||
void markChanged({
|
||||
Object? source,
|
||||
List<MessageSummary> updated = const <MessageSummary>[],
|
||||
Set<String> removedIds = const <String>{},
|
||||
}) {
|
||||
if (!ref.mounted) return;
|
||||
state = MailChange(state.seq + 1, source: source);
|
||||
state = MailChange(
|
||||
state.seq + 1,
|
||||
source: source,
|
||||
updated: List<MessageSummary>.unmodifiable(updated),
|
||||
removedIds: Set<String>.unmodifiable(removedIds),
|
||||
);
|
||||
}
|
||||
|
||||
void _onActiveChanged() {
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
import 'dart:math' as math;
|
||||
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:arasystem_app/core/api/dio_provider.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/features/mail/data/models/mail_bulk_results.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
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/mail_repository.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_html.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_images.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/message_reader_state.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
|
||||
/// 본문 조각을 만드는 일 — 정화([mailBodyFragment]). 시험은 덮어쓸 수 있다
|
||||
typedef MailBodyBuilder =
|
||||
Future<SanitizedMailBody> Function({
|
||||
required String? htmlBody,
|
||||
required String? textBody,
|
||||
required bool imagesShown,
|
||||
});
|
||||
|
||||
/// 본문 조각 만들기 — 짧고 얕은 본문은 그 자리에서, 길거나 깊은 본문은 [Isolate.run] 으로([mailBodyBuildPlaceOf])
|
||||
final Provider<MailBodyBuilder> mailBodyBuilderProvider =
|
||||
Provider<MailBodyBuilder>((Ref ref) => buildMailBody);
|
||||
|
||||
/// [mailBodyBuilderProvider] 의 기본 — 2MB 까지 오는 본문 · 짧아도 깊은 본문을 정화하는 동안 화면이 멈추지 않게 곳을 고른다.
|
||||
/// 처음 받은 본문과 [이미지 보기]로 다시 받은 본문이 같은 길을 간다
|
||||
Future<SanitizedMailBody> buildMailBody({
|
||||
required String? htmlBody,
|
||||
required String? textBody,
|
||||
required bool imagesShown,
|
||||
}) async {
|
||||
SanitizedMailBody build() => mailBodyFragment(
|
||||
htmlBody: htmlBody,
|
||||
textBody: textBody,
|
||||
imagesShown: imagesShown,
|
||||
);
|
||||
final MailBodyBuildPlace place = mailBodyBuildPlaceOf(
|
||||
htmlBody: htmlBody,
|
||||
textBody: textBody,
|
||||
);
|
||||
if (place == MailBodyBuildPlace.inline) return build();
|
||||
return Isolate.run(build);
|
||||
}
|
||||
|
||||
/// 메일 한 통의 읽기 화면 — 주소의 메일 id 마다 하나. 화면이 있는 동안만 둔다(autoDispose) — 다른 메일을 열면 앞 응답은 버린다.
|
||||
final NotifierProviderFamily<
|
||||
MessageReaderController,
|
||||
MessageReaderState,
|
||||
String
|
||||
>
|
||||
messageReaderProvider = NotifierProvider.autoDispose
|
||||
.family<MessageReaderController, MessageReaderState, String>(
|
||||
MessageReaderController.new,
|
||||
);
|
||||
|
||||
/// 읽기 화면 — 계약 2절 `GET /api/messages/{id}` · 읽음 · 중요 · 옮기기 · 스팸 · 삭제 · 9단계 "보내기 취소 · 예약 발송" · "외부 이미지 보기"
|
||||
/// (웹 `stores/mail/message.ts` · `useMessageRemoval` · `useScheduledMessageActions` 와 같은 규칙)
|
||||
///
|
||||
/// - 열면 안 읽은 메일을 읽음으로 바꾼다(화면 · 메일함 개수를 먼저 — 실패하면 알리지 않고 되돌린다).
|
||||
/// - 본문은 두 번째 정화 뒤 CSP 를 건 문서로 만든다(`mail_html.dart` — 짧고 얕은 본문만 그 자리에서, 길거나 깊은 본문은 다른 isolate 에서).
|
||||
/// 외부 이미지를 보이는 본문이면(설정 · [이미지 보기]) 중계 그림을 Dio 로 받아(한 번에 [kMailImageConcurrency] 개 · 한도 `mail_images.dart`)
|
||||
/// `data:` 로 넣고 문서를 한 번 더 만든다. 그림 받기는 화면이 사라지거나 본문이 바뀌면 끊는다(끊긴 그림은 숨기고 알리지 않는다).
|
||||
/// - 옮기기 · 스팸 · 휴지통은 응답을 받은 뒤 목록으로 돌아가고(부르는 쪽이), 알림에 [되돌리기](원래 메일함 — 스팸은 표시도 반대로).
|
||||
/// 휴지통에만 든 메일은 확인 뒤 영구 삭제(`POST /api/message-deletions`) — `DELETE` 는 쓰지 않는다(웹과 같다).
|
||||
/// - 붙잡힌 메일(보내기 취소를 기다림 · 예약)은 옮기거나 스팸으로 보내지 않는다 — [보내기 취소] · [예약 취소]는 임시보관함으로,
|
||||
/// [삭제]는 확인 뒤 취소하고 휴지통으로. 보낼 시각 + 2초에 다시 보고(서버 시계) 풀렸으면 안내 줄을 지운다.
|
||||
/// - 늦은 응답: 받기 세대([_seq]) · 본문 세대([_bodySeq])가 바뀌었거나 화면이 사라졌으면 버린다.
|
||||
/// 세션이 바뀌었으면(토큰 보관의 세션 세대) 알림 · [되돌리기] · 목록 알리기를 하지 않는다.
|
||||
/// - 응답은 그 요청이 바꾼 칸(읽음 · 중요 · 메일함)만 지금 요약에 덮는다 — 그사이 다른 요청이 바꾼 칸 · 다시 받은 본문을 되돌리지 않게.
|
||||
/// - 목록 · 메일함 개수는 새 메일 확인의 소식([MailStateWatcher.markChanged] — 그렇게 맞춘 요약을 싣는다)으로 맞춘다.
|
||||
class MessageReaderController extends Notifier<MessageReaderState> {
|
||||
MessageReaderController(this.messageId);
|
||||
|
||||
/// 주소의 메일 id
|
||||
final String messageId;
|
||||
|
||||
int _seq = 0;
|
||||
int _bodySeq = 0;
|
||||
SanitizedMailBody? _body;
|
||||
Map<String, String> _images = const <String, String>{};
|
||||
|
||||
/// 지금 본문의 중계 그림 받기 — 화면이 사라지거나 본문이 바뀌면(다시 받기 · [이미지 보기]) 끊는다
|
||||
CancelToken? _imagesCancel;
|
||||
|
||||
/// 서버 시계 − 기기 시계 — 받은 응답의 `Date` 로 잰다(붙잡아 둔 발송이 풀릴 때를 서버 시계로 잰다)
|
||||
Duration _serverOffset = Duration.zero;
|
||||
Timer? _heldTimer;
|
||||
int _heldRechecks = 0;
|
||||
|
||||
/// 연 메일의 읽음 처리 — [setRead] 가 먼저 끝나기를 기다린다(뒤에 보낸 것이 이기게)
|
||||
Future<void>? _reading;
|
||||
bool _flagging = false;
|
||||
|
||||
MailRepository get _repository => ref.read(mailRepositoryProvider);
|
||||
|
||||
Clock get _clock => ref.read(clockProvider);
|
||||
|
||||
@override
|
||||
MessageReaderState build() {
|
||||
final String? account = ref.watch(mailAccountProvider);
|
||||
_seq++;
|
||||
_bodySeq++;
|
||||
_body = null;
|
||||
_images = const <String, String>{};
|
||||
_serverOffset = Duration.zero;
|
||||
_heldRechecks = 0;
|
||||
_reading = null;
|
||||
_flagging = false;
|
||||
_cancelHeldTimer();
|
||||
_cancelImages();
|
||||
ref
|
||||
..onDispose(_cancelHeldTimer)
|
||||
..onDispose(_cancelImages);
|
||||
// [되돌리기] 알림이 이 화면에서 목록으로 돌아가는 동안 사라지지 않게 쥔다(목록 화면이 이어 쥔다)
|
||||
ref.listen<MailToast?>(mailToastProvider, (MailToast? _, MailToast? _) {});
|
||||
// 세션이 없으면(로그아웃하며 화면이 사라지는 중) 묻지 않는다
|
||||
if (account == null) return const MessageReaderState();
|
||||
unawaited(_load(_seq));
|
||||
return const MessageReaderState();
|
||||
}
|
||||
|
||||
// ── 받기 ─────────────────────────────────────────
|
||||
|
||||
Future<void> _load(int seq) async {
|
||||
final Ref ref = this.ref;
|
||||
try {
|
||||
final ({MessageDetail detail, DateTime? serverTime}) result =
|
||||
await _repository.getMessage(messageId);
|
||||
if (!ref.mounted || seq != _seq) return;
|
||||
_noteServerTime(result.serverTime);
|
||||
state = MessageReaderState(
|
||||
status: MessageReaderStatus.ready,
|
||||
detail: result.detail,
|
||||
);
|
||||
_scheduleHeldCheck();
|
||||
unawaited(_buildBody(result.detail));
|
||||
if (!result.detail.summary.isRead) _reading = _markReadOnOpen();
|
||||
} on DioException catch (error) {
|
||||
if (!ref.mounted || seq != _seq) return;
|
||||
state = MessageReaderState(
|
||||
status: MessageReaderStatus.failed,
|
||||
error: toApiException(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [다시 시도]
|
||||
Future<void> retry() async {
|
||||
if (!ref.mounted) return;
|
||||
_seq++;
|
||||
_bodySeq++;
|
||||
_cancelImages();
|
||||
state = const MessageReaderState();
|
||||
await _load(_seq);
|
||||
}
|
||||
|
||||
void _noteServerTime(DateTime? serverTime) {
|
||||
if (serverTime != null) _serverOffset = serverTime.difference(_clock());
|
||||
}
|
||||
|
||||
DateTime _serverNow() => _clock().add(_serverOffset);
|
||||
|
||||
void _setSummary(MessageSummary Function(MessageSummary current) change) {
|
||||
final MessageDetail? detail = state.detail;
|
||||
if (detail == null) return;
|
||||
state = state.copyWith(detail: detail.withSummary(change(detail.summary)));
|
||||
}
|
||||
|
||||
/// 응답([response])에서 그 요청이 바꾼 칸([changes])만 **지금** 요약에 덮는다 — 그사이 다른 요청이 바꾼 칸(중요 · 읽음)과
|
||||
/// 다시 받은 본문([이미지 보기])을 응답의 옛 값 · 요청 전에 쥔 값으로 되돌리지 않게. 목록에 알릴 요약을 돌려준다(화면이 없으면 응답 그대로)
|
||||
MessageSummary _applyResponse(
|
||||
MessageChanges changes,
|
||||
MessageSummary response,
|
||||
) {
|
||||
final MessageDetail? detail = ref.mounted ? state.detail : null;
|
||||
if (detail == null) return response;
|
||||
final bool moved = changes.mailboxId != null || changes.isJunk != null;
|
||||
final MessageSummary merged = detail.summary.copyWith(
|
||||
isRead: changes.isRead == null ? null : response.isRead,
|
||||
isFlagged: changes.isFlagged == null ? null : response.isFlagged,
|
||||
mailboxIds: moved ? response.mailboxIds : null,
|
||||
);
|
||||
state = state.copyWith(detail: detail.withSummary(merged));
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ── 본문 ─────────────────────────────────────────
|
||||
|
||||
/// 본문 문서를 만든다 — 정화한 문서를 먼저 보이고, 중계 그림이 있으면 받아 넣은 문서로 한 번 더 바꾼다
|
||||
Future<void> _buildBody(MessageDetail detail) async {
|
||||
final Ref ref = this.ref;
|
||||
final int bodySeq = ++_bodySeq;
|
||||
// 앞 본문의 그림 받기를 끊는다 — 그 문서는 이제 쓰지 않는다
|
||||
_cancelImages();
|
||||
bool alive() => ref.mounted && bodySeq == _bodySeq;
|
||||
final MailBodyBuilder buildBody = ref.read(mailBodyBuilderProvider);
|
||||
SanitizedMailBody body;
|
||||
try {
|
||||
body = await buildBody(
|
||||
htmlBody: detail.htmlBody,
|
||||
textBody: detail.textBody,
|
||||
imagesShown: detail.imagesShown,
|
||||
);
|
||||
} on Object {
|
||||
// 정화가 뜻밖에 실패하면 서식 없이 글자 본문만 보인다
|
||||
body = mailBodyFragment(
|
||||
htmlBody: null,
|
||||
textBody: detail.textBody,
|
||||
imagesShown: false,
|
||||
);
|
||||
}
|
||||
if (!alive()) return;
|
||||
_body = body;
|
||||
_images = const <String, String>{};
|
||||
final bool hasImages = body.proxiedTokens.isNotEmpty;
|
||||
state = state.copyWith(
|
||||
document: _document(),
|
||||
bodyOmitted: body.htmlOmitted,
|
||||
imagesLoading: hasImages,
|
||||
);
|
||||
if (!hasImages) return;
|
||||
final CancelToken cancel = _imagesCancel = CancelToken();
|
||||
final Map<String, String> loaded = await _fetchImages(
|
||||
body.proxiedTokens,
|
||||
alive,
|
||||
cancel,
|
||||
);
|
||||
if (!alive()) return;
|
||||
_images = loaded;
|
||||
state = state.copyWith(document: _document(), imagesLoading: false);
|
||||
}
|
||||
|
||||
/// WebView 에 넣을 문서 — 중계 그림은 받은 것만, 문서에 싣는 그림 글은 나온 횟수까지 세어 한도 안에서([resolveProxiedImages])
|
||||
String? _document() {
|
||||
final SanitizedMailBody? body = _body;
|
||||
if (body == null) return null;
|
||||
return buildMailDocument(
|
||||
body.proxiedTokens.isEmpty
|
||||
? body.fragment
|
||||
: resolveProxiedImages(body.fragment, _images),
|
||||
);
|
||||
}
|
||||
|
||||
void _cancelImages() {
|
||||
_imagesCancel?.cancel();
|
||||
_imagesCancel = null;
|
||||
}
|
||||
|
||||
/// 중계 그림을 받는다 — 한 번에 [kMailImageConcurrency] 개, 그림 하나 · 합계 한도를 넘거나 그림이 아니면 그 자리를 비운다.
|
||||
/// 실패 · 끊김([cancel] — 화면을 떠남 · 본문이 바뀜)은 알리지 않는다(웹도 받지 못한 그림 자리는 비어 있다)
|
||||
Future<Map<String, String>> _fetchImages(
|
||||
List<String> tokens,
|
||||
bool Function() alive,
|
||||
CancelToken cancel,
|
||||
) async {
|
||||
final MailRepository repository = _repository;
|
||||
final Map<String, String> loaded = <String, String>{};
|
||||
int used = 0;
|
||||
int next = 0;
|
||||
Future<void> worker() async {
|
||||
while (next < tokens.length &&
|
||||
alive() &&
|
||||
!cancel.isCancelled &&
|
||||
used < kMailImagesTotalMaxBytes) {
|
||||
final String token = tokens[next++];
|
||||
try {
|
||||
final Uint8List bytes = await repository.fetchMailImage(
|
||||
token,
|
||||
maxBytes: kMailImageMaxBytes,
|
||||
cancelToken: cancel,
|
||||
);
|
||||
if (!alive()) return;
|
||||
final String? data = acceptMailImage(bytes, usedBytes: used);
|
||||
if (data == null) continue;
|
||||
loaded[token] = data;
|
||||
used += bytes.length;
|
||||
} on DioException {
|
||||
// 그 자리를 비운다
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Future.wait(<Future<void>>[
|
||||
for (
|
||||
int index = 0;
|
||||
index < math.min(kMailImageConcurrency, tokens.length);
|
||||
index++
|
||||
)
|
||||
worker(),
|
||||
]);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/// [이미지 보기] — 이 메일을 `images=show` 로 다시 받아 본문만 바꾼다(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹과 같다).
|
||||
/// 다시 열면 설정대로다. 실패는 인터셉터가 알리고 안내 줄은 그대로 남는다
|
||||
Future<void> showImages() async {
|
||||
final Ref ref = this.ref;
|
||||
final MessageDetail? current = state.detail;
|
||||
if (!ref.mounted ||
|
||||
current == null ||
|
||||
current.imagesShown ||
|
||||
state.imagesLoading) {
|
||||
return;
|
||||
}
|
||||
final int seq = _seq;
|
||||
state = state.copyWith(imagesLoading: true);
|
||||
try {
|
||||
final ({MessageDetail detail, DateTime? serverTime}) result =
|
||||
await _repository.getMessage(
|
||||
messageId,
|
||||
showImages: true,
|
||||
silent: false,
|
||||
);
|
||||
if (!ref.mounted || seq != _seq) return;
|
||||
final MessageDetail next = (state.detail ?? current).withBodyOf(
|
||||
result.detail,
|
||||
);
|
||||
state = state.copyWith(detail: next);
|
||||
await _buildBody(next);
|
||||
} on DioException {
|
||||
if (!ref.mounted || seq != _seq) return;
|
||||
state = state.copyWith(imagesLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 읽음 · 중요 ─────────────────────────────────────
|
||||
|
||||
/// 연 메일의 읽음 처리 — 화면 · 메일함 개수를 먼저 바꾸고, 실패하면 알리지 않고 되돌린다
|
||||
Future<void> _markReadOnOpen() async {
|
||||
final Ref ref = this.ref;
|
||||
final MessageDetail? detail = state.detail;
|
||||
if (detail == null || detail.summary.isRead) return;
|
||||
final MailRepository repository = _repository;
|
||||
final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier);
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
final List<String> boxes = detail.summary.mailboxIds;
|
||||
_setSummary((MessageSummary s) => s.copyWith(isRead: true));
|
||||
mailboxes.adjustUnread(boxes, -1);
|
||||
const MessageChanges changes = MessageChanges(isRead: true);
|
||||
try {
|
||||
final MessageSummary summary = await repository.updateMessage(
|
||||
messageId,
|
||||
changes,
|
||||
silent: true,
|
||||
);
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
watcher.markChanged(
|
||||
updated: <MessageSummary>[_applyResponse(changes, summary)],
|
||||
);
|
||||
} on DioException {
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
mailboxes.adjustUnread(boxes, 1);
|
||||
if (ref.mounted) {
|
||||
_setSummary((MessageSummary s) => s.copyWith(isRead: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [안 읽음 표시] · [읽음 표시] — 응답을 받은 뒤 화면을 바꾼다. 성공하면 참([안 읽음 표시]면 부르는 쪽이 목록으로 돌아간다)
|
||||
Future<bool> setRead(bool read) async {
|
||||
final Ref ref = this.ref;
|
||||
if (!ref.mounted || state.busy != null || state.detail == null) {
|
||||
return false;
|
||||
}
|
||||
final Future<void>? reading = _reading;
|
||||
if (reading != null) await reading;
|
||||
final MessageDetail? detail = state.detail;
|
||||
if (!ref.mounted || detail == null || state.busy != null) return false;
|
||||
if (detail.summary.isRead == read) return true;
|
||||
final MailRepository repository = _repository;
|
||||
final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier);
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
final MessageChanges changes = MessageChanges(isRead: read);
|
||||
state = state.copyWith(busy: MessageReaderBusy.marking);
|
||||
try {
|
||||
final MessageSummary summary = await repository.updateMessage(
|
||||
messageId,
|
||||
changes,
|
||||
);
|
||||
if (tokens.sessionGeneration != session) return false;
|
||||
mailboxes.adjustUnread(detail.summary.mailboxIds, read ? -1 : 1);
|
||||
// 요청 전에 쥔 메일 정보(detail)로 되쓰지 않는다 — 그사이 [이미지 보기]가 본문을 바꿨을 수 있다
|
||||
watcher.markChanged(
|
||||
updated: <MessageSummary>[_applyResponse(changes, summary)],
|
||||
);
|
||||
if (ref.mounted) state = state.copyWith(clearBusy: true);
|
||||
return true;
|
||||
} on DioException {
|
||||
if (ref.mounted) state = state.copyWith(clearBusy: true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// [중요 표시] · [중요 해제] — 화면을 먼저 바꾸고 실패하면 되돌린다. 바꾸면 알림(읽기 화면에는 별이 없다)
|
||||
Future<void> toggleFlag() async {
|
||||
final Ref ref = this.ref;
|
||||
final MessageDetail? detail = state.detail;
|
||||
if (!ref.mounted || detail == null || _flagging) return;
|
||||
final bool next = !detail.summary.isFlagged;
|
||||
final MailRepository repository = _repository;
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
_flagging = true;
|
||||
_setSummary((MessageSummary s) => s.copyWith(isFlagged: next));
|
||||
final MessageChanges changes = MessageChanges(isFlagged: next);
|
||||
try {
|
||||
final MessageSummary summary = await repository.updateMessage(
|
||||
messageId,
|
||||
changes,
|
||||
);
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
watcher.markChanged(
|
||||
updated: <MessageSummary>[_applyResponse(changes, summary)],
|
||||
);
|
||||
notice(next ? MailReaderTexts.flagged : MailReaderTexts.unflagged);
|
||||
} on DioException {
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
if (ref.mounted) {
|
||||
_setSummary((MessageSummary s) => s.copyWith(isFlagged: !next));
|
||||
}
|
||||
} finally {
|
||||
_flagging = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 옮기기 · 스팸 · 삭제 ─────────────────────────────────
|
||||
|
||||
/// 휴지통으로 — 성공하면 참(목록으로 돌아간다). 휴지통을 모르면 메일함 목록을 받아 보고, 그래도 모르면 알린다
|
||||
Future<bool> moveToTrash() async {
|
||||
final Mailbox? trash = await _mailboxOfRole(MailboxRole.trash);
|
||||
if (trash == null) return false;
|
||||
return _move(trash, text: MailBulkTexts.moved(1, trash.name));
|
||||
}
|
||||
|
||||
/// [이동] — 고른 메일함으로
|
||||
Future<bool> moveToMailbox(Mailbox target) =>
|
||||
_move(target, text: MailBulkTexts.moved(1, target.name));
|
||||
|
||||
/// [스팸 신고](스팸메일함으로 + 스팸 표시) · [스팸 아님](받은편지함으로 + 스팸 아님 표시) — 그 메일의 메일함으로 정한다
|
||||
Future<bool> toggleSpam() async {
|
||||
final MessageDetail? detail = state.detail;
|
||||
if (detail == null) return false;
|
||||
final Mailbox? junk = await _mailboxOfRole(MailboxRole.junk);
|
||||
if (junk == null) return false;
|
||||
final bool inJunk = detail.summary.mailboxIds.contains(junk.id);
|
||||
if (!inJunk) {
|
||||
return _move(junk, isJunk: true, text: MailBulkTexts.moved(1, junk.name));
|
||||
}
|
||||
final Mailbox? inbox = await _mailboxOfRole(MailboxRole.inbox);
|
||||
if (inbox == null) return false;
|
||||
return _move(
|
||||
inbox,
|
||||
isJunk: false,
|
||||
text: MailBulkTexts.notSpamMoved(1, inbox.name),
|
||||
);
|
||||
}
|
||||
|
||||
/// 역할의 메일함 — 모르면 메일함 목록을 다시 받아 본다. 그래도 모르면 알리고 null (옮기지 않는다)
|
||||
Future<Mailbox?> _mailboxOfRole(MailboxRole role) async {
|
||||
final Ref ref = this.ref;
|
||||
if (!ref.mounted) return null;
|
||||
Mailbox? mailbox = ref.read(mailboxesProvider).byRole(role);
|
||||
if (mailbox != null) return mailbox;
|
||||
await ref.read(mailboxesProvider.notifier).reload();
|
||||
if (!ref.mounted) return null;
|
||||
final MailboxesState mailboxes = ref.read(mailboxesProvider);
|
||||
mailbox = mailboxes.byRole(role);
|
||||
if (mailbox == null) {
|
||||
ref
|
||||
.read(errorNotifierProvider)
|
||||
.show(mailboxes.failure ?? ApiException.fromCode('BIZ_001'));
|
||||
}
|
||||
return mailbox;
|
||||
}
|
||||
|
||||
/// [target] 으로 옮긴다 — 응답을 받으면 목록에 알리고 알림에 [되돌리기](원래 메일함 · 스팸 표시는 반대로)
|
||||
Future<bool> _move(
|
||||
Mailbox target, {
|
||||
required String text,
|
||||
bool? isJunk,
|
||||
}) async {
|
||||
final Ref ref = this.ref;
|
||||
final MessageDetail? detail = state.detail;
|
||||
if (!ref.mounted ||
|
||||
detail == null ||
|
||||
state.busy != null ||
|
||||
detail.summary.isHeld) {
|
||||
return false;
|
||||
}
|
||||
final MailRepository repository = _repository;
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final MailToastNotifier toast = ref.read(mailToastProvider.notifier);
|
||||
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
final String? origin = _originOf(detail.summary, target.id);
|
||||
final MessageChanges changes = MessageChanges(
|
||||
mailboxId: target.id,
|
||||
isJunk: isJunk,
|
||||
);
|
||||
state = state.copyWith(busy: MessageReaderBusy.removing);
|
||||
try {
|
||||
final MessageSummary summary = await repository.updateMessage(
|
||||
messageId,
|
||||
changes,
|
||||
);
|
||||
// 끝난 세션의 결과는 새 세션에 알리지 않는다 — [되돌리기]도 없다
|
||||
if (tokens.sessionGeneration != session) return false;
|
||||
watcher.markChanged(
|
||||
updated: <MessageSummary>[_applyResponse(changes, summary)],
|
||||
);
|
||||
if (origin == null) {
|
||||
notice(text);
|
||||
} else {
|
||||
toast.offer(
|
||||
text,
|
||||
actionLabel: MailBulkTexts.undo,
|
||||
onAction: () => _restoreMessage(
|
||||
messageId,
|
||||
mailboxId: origin,
|
||||
isJunk: isJunk == null ? null : !isJunk,
|
||||
repository: repository,
|
||||
watcher: watcher,
|
||||
notice: notice,
|
||||
tokens: tokens,
|
||||
session: session,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (ref.mounted) state = state.copyWith(clearBusy: true);
|
||||
return true;
|
||||
} on DioException {
|
||||
if (ref.mounted) state = state.copyWith(clearBusy: true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 되돌릴 메일함 — 옮길 곳이 아닌 첫 메일함
|
||||
String? _originOf(MessageSummary summary, String targetId) {
|
||||
for (final String id in summary.mailboxIds) {
|
||||
if (id != targetId) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 영구 삭제 — 확인 창에서 [영구 삭제]를 누른 뒤에만 부른다. 지웠으면 참(목록으로 돌아간다).
|
||||
/// 서버가 그대로 두었으면(그사이 휴지통을 떠남) 알리고 메일 정보를 다시 받는다
|
||||
Future<bool> purge() async {
|
||||
final Ref ref = this.ref;
|
||||
final MessageDetail? detail = state.detail;
|
||||
if (!ref.mounted || detail == null || state.busy != null) return false;
|
||||
final MailRepository repository = _repository;
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
state = state.copyWith(busy: MessageReaderBusy.removing);
|
||||
try {
|
||||
final MessageDeletions result = await repository.deleteMessages(<String>[
|
||||
messageId,
|
||||
]);
|
||||
if (tokens.sessionGeneration != session) return false;
|
||||
final bool deleted = result.deletedIds.contains(messageId);
|
||||
if (deleted) {
|
||||
watcher.markChanged(removedIds: <String>{messageId});
|
||||
notice(MailBulkTexts.purged(1));
|
||||
} else {
|
||||
watcher.markChanged();
|
||||
notice(MailBulkTexts.purged(0, skipped: 1));
|
||||
}
|
||||
if (ref.mounted) {
|
||||
state = state.copyWith(clearBusy: true);
|
||||
if (!deleted) unawaited(_refreshSummary());
|
||||
}
|
||||
return deleted;
|
||||
} on DioException {
|
||||
if (ref.mounted) state = state.copyWith(clearBusy: true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 붙잡힌 메일 ─────────────────────────────────────
|
||||
|
||||
/// [보내기 취소] · [예약 취소] — 보내지 않고 임시보관함으로. 취소했으면 참(목록으로 돌아간다).
|
||||
/// 이미 보냈으면(`MAIL_014` — 인터셉터가 알렸다) 메일 정보를 다시 받아 안내 줄을 맞춘다
|
||||
Future<bool> cancelHeld() async {
|
||||
final Ref ref = this.ref;
|
||||
final PendingSend? pending = state.detail?.summary.pendingSend;
|
||||
if (!ref.mounted || pending == null || state.busy != null) return false;
|
||||
final MailRepository repository = _repository;
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
state = state.copyWith(busy: MessageReaderBusy.cancelling);
|
||||
try {
|
||||
await repository.cancelPendingSend(messageId);
|
||||
if (tokens.sessionGeneration != session) return false;
|
||||
watcher.markChanged(removedIds: <String>{messageId});
|
||||
notice(
|
||||
pending.kind == PendingSendKind.scheduled
|
||||
? MailReaderTexts.scheduleCanceled
|
||||
: MailReaderTexts.undoSendDone,
|
||||
);
|
||||
if (ref.mounted) state = state.copyWith(clearBusy: true);
|
||||
return true;
|
||||
} on DioException {
|
||||
if (!ref.mounted) return false;
|
||||
state = state.copyWith(clearBusy: true);
|
||||
unawaited(_refreshSummary());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 보내지 않은 메일의 [삭제] — 확인 창 뒤에만 부른다. 발송을 취소한 뒤 휴지통으로 옮긴다
|
||||
/// (취소하지 않고 옮기면 엔진은 그 시각에 그대로 보낸다). 옮겼으면 참(목록으로 돌아간다)
|
||||
Future<bool> deleteHeld() async {
|
||||
final Ref ref = this.ref;
|
||||
if (!ref.mounted || !state.isHeld || state.busy != null) return false;
|
||||
final MailRepository repository = _repository;
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
state = state.copyWith(busy: MessageReaderBusy.cancelling);
|
||||
final String draftId;
|
||||
try {
|
||||
draftId = await repository.cancelPendingSend(messageId);
|
||||
} on DioException {
|
||||
if (!ref.mounted) return false;
|
||||
state = state.copyWith(clearBusy: true);
|
||||
unawaited(_refreshSummary());
|
||||
return false;
|
||||
}
|
||||
if (tokens.sessionGeneration != session) return false;
|
||||
// 발송은 취소했다 — 메일은 임시보관함에 있다(보낸편지함 목록에서 뺀다)
|
||||
watcher.markChanged(removedIds: <String>{messageId});
|
||||
final Mailbox? trash = await _mailboxOfRole(MailboxRole.trash);
|
||||
if (tokens.sessionGeneration != session) return false;
|
||||
if (trash != null) {
|
||||
try {
|
||||
final MessageSummary summary = await repository.updateMessage(
|
||||
draftId,
|
||||
MessageChanges(mailboxId: trash.id),
|
||||
);
|
||||
if (tokens.sessionGeneration != session) return false;
|
||||
watcher.markChanged(updated: <MessageSummary>[summary]);
|
||||
notice(MailReaderTexts.heldDeleted);
|
||||
if (ref.mounted) state = state.copyWith(clearBusy: true);
|
||||
return true;
|
||||
} on DioException {
|
||||
// 옮기지 못했다 — 알림은 인터셉터가 띄웠다. 메일은 임시보관함에 남는다
|
||||
}
|
||||
}
|
||||
if (ref.mounted) {
|
||||
state = state.copyWith(clearBusy: true);
|
||||
unawaited(_refreshSummary());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 메일 정보만 다시 받는다(본문은 그대로) — 붙잡힌 발송이 풀렸는지 · 그사이 바뀐 메일함. 실패는 알리지 않는다
|
||||
Future<void> _refreshSummary() async {
|
||||
final Ref ref = this.ref;
|
||||
final int seq = _seq;
|
||||
try {
|
||||
final ({MessageDetail detail, DateTime? serverTime}) result =
|
||||
await _repository.getMessage(messageId);
|
||||
if (!ref.mounted || seq != _seq) return;
|
||||
_noteServerTime(result.serverTime);
|
||||
_setSummary((MessageSummary _) => result.detail.summary);
|
||||
} on DioException {
|
||||
// 다음 차례에 다시 본다
|
||||
}
|
||||
if (ref.mounted && seq == _seq) _scheduleHeldCheck();
|
||||
}
|
||||
|
||||
/// 붙잡힌 발송이 풀리는 때(보낼 시각 + 2초, 서버 시계)에 메일 정보를 다시 본다 — 그 뒤에도 붙잡힌 것으로 보이면 5초마다 3번 더
|
||||
/// (엔진이 풀어도 새 메일 확인의 상태는 바뀌지 않는다 — 목록 컨트롤러와 같은 규칙)
|
||||
void _scheduleHeldCheck() {
|
||||
_cancelHeldTimer();
|
||||
final PendingSend? pending = state.detail?.summary.pendingSend;
|
||||
if (!ref.mounted || pending == null) return;
|
||||
final DateTime now = _serverNow();
|
||||
final DateTime due = pending.sendAt.add(kHeldReleaseGrace);
|
||||
final Duration delay;
|
||||
if (due.isAfter(now)) {
|
||||
delay = due.difference(now);
|
||||
} else {
|
||||
if (_heldRechecks >= kHeldReleaseMaxRechecks) return;
|
||||
delay = kHeldReleaseRecheck;
|
||||
}
|
||||
_heldTimer = Timer(delay, () {
|
||||
_heldTimer = null;
|
||||
if (!ref.mounted) return;
|
||||
if (!due.isAfter(_serverNow())) _heldRechecks++;
|
||||
unawaited(_refreshSummary());
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelHeldTimer() {
|
||||
_heldTimer?.cancel();
|
||||
_heldTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// [되돌리기] — 옮긴 메일을 원래 메일함으로(스팸 표시는 반대로) 다시 옮긴 뒤 목록 · 메일함 개수를 맞춘다.
|
||||
///
|
||||
/// 알림이 읽기 화면보다 오래 남으므로(목록에서 누른다) 화면 없이 돈다. 만든 세션에서만 움직인다(메일 id 는 계정마다 겹칠 수 있다)
|
||||
Future<void> _restoreMessage(
|
||||
String messageId, {
|
||||
required String mailboxId,
|
||||
required bool? isJunk,
|
||||
required MailRepository repository,
|
||||
required MailStateWatcher watcher,
|
||||
required void Function(String text) notice,
|
||||
required TokenStore tokens,
|
||||
required int session,
|
||||
}) async {
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
try {
|
||||
final MessageSummary summary = await repository.updateMessage(
|
||||
messageId,
|
||||
MessageChanges(mailboxId: mailboxId, isJunk: isJunk),
|
||||
);
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
watcher.markChanged(updated: <MessageSummary>[summary]);
|
||||
notice(MailBulkTexts.restored(1));
|
||||
} on DioException catch (error) {
|
||||
// 알림은 인터셉터가 띄웠다 — 그사이 지워졌으면(없음) 목록만 맞춘다
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
if (apiErrorCodeOf(error) == 'RES_001') watcher.markChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:arasystem_app/core/api/api_exception.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 읽기 화면의 상태
|
||||
enum MessageReaderStatus {
|
||||
/// 메일을 받는 중
|
||||
loading,
|
||||
|
||||
/// 받았다
|
||||
ready,
|
||||
|
||||
/// 받지 못했다 — 받지 못함 + [다시 시도] (없는 메일은 [목록으로])
|
||||
failed,
|
||||
}
|
||||
|
||||
/// 하는 중인 일 — 끝날 때까지 위 막대 · 안내 줄의 단추를 막는다
|
||||
enum MessageReaderBusy {
|
||||
/// 옮기기 · 스팸 · 휴지통 · 영구 삭제
|
||||
removing,
|
||||
|
||||
/// 붙잡아 둔 발송 취소(보내기 취소 · 예약 취소 · 보내지 않은 메일의 삭제)
|
||||
cancelling,
|
||||
|
||||
/// 읽음 표시 바꾸기([안 읽음 표시]는 끝나면 목록으로)
|
||||
marking,
|
||||
}
|
||||
|
||||
/// 메일 한 통의 읽기 화면
|
||||
@immutable
|
||||
class MessageReaderState {
|
||||
const MessageReaderState({
|
||||
this.status = MessageReaderStatus.loading,
|
||||
this.detail,
|
||||
this.error,
|
||||
this.document,
|
||||
this.bodyOmitted = false,
|
||||
this.imagesLoading = false,
|
||||
this.busy,
|
||||
});
|
||||
|
||||
final MessageReaderStatus status;
|
||||
final MessageDetail? detail;
|
||||
|
||||
/// 받지 못한 까닭 (코드 · 추적 ID)
|
||||
final ApiException? error;
|
||||
|
||||
/// WebView 에 넣을 문서(정화 · CSP 를 마쳤다) — 만들기 전이면 null
|
||||
final String? document;
|
||||
|
||||
/// 서식 본문이 앱의 정화 한도를 넘어 글자 본문을 보인다 — 서버의 생략(`isHtmlBodyOmitted`)과 같은 안내를 보인다
|
||||
final bool bodyOmitted;
|
||||
|
||||
/// 본문 안내에 쓸 생략 표시 — 서버가 비웠거나 앱이 정화하지 않았다
|
||||
bool get htmlOmitted => (detail?.isHtmlBodyOmitted ?? false) || bodyOmitted;
|
||||
|
||||
/// [이미지 보기]로 다시 받는 중 · 중계 그림을 받아 넣는 중
|
||||
final bool imagesLoading;
|
||||
|
||||
/// 하는 중인 일 — 없으면 null
|
||||
final MessageReaderBusy? busy;
|
||||
|
||||
/// 아직 보내지 않은 내 메일(보내기 취소를 기다림 · 예약) — 취소로만 보낸편지함을 떠난다
|
||||
bool get isHeld => detail?.summary.isHeld ?? false;
|
||||
|
||||
MessageReaderState copyWith({
|
||||
MessageDetail? detail,
|
||||
String? document,
|
||||
bool? bodyOmitted,
|
||||
bool? imagesLoading,
|
||||
MessageReaderBusy? busy,
|
||||
bool clearBusy = false,
|
||||
}) => MessageReaderState(
|
||||
status: status,
|
||||
detail: detail ?? this.detail,
|
||||
error: error,
|
||||
document: document ?? this.document,
|
||||
bodyOmitted: bodyOmitted ?? this.bodyOmitted,
|
||||
imagesLoading: imagesLoading ?? this.imagesLoading,
|
||||
busy: clearBusy ? null : (busy ?? this.busy),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/features/mail/domain/mail_links.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
|
||||
/// 메일 본문 — 앱이 만든 문서([document] — 두 번째 정화 · CSP 를 마쳤다 `mail_html.dart`)만 넣는 WebView 한 겹.
|
||||
///
|
||||
/// - 스크립트를 끈다. 문서 안의 CSP 가 모든 바깥 요청을 막는다(그림은 `data:` 만 — 외부 이미지는 앱이 받아 넣었다).
|
||||
/// - 주 틀의 탐색은 모두 막는다 — 처음 넣은 문서(`about:blank`)만 열고, 누른 링크는 [onNavigation] 으로 앱에 넘긴다(`mail_links.dart`).
|
||||
/// 아래 틀(iframe)의 탐색은 안드로이드가 이 판정에 넘기지 않는다(WebView 가 그대로 연다) — 아래 틀은 정화(iframe 을 내용째 버림)와
|
||||
/// 문서의 CSP(`default-src 'none'` — frame-src)가 막는다.
|
||||
/// - 안드로이드: 파일 · content 주소 접근 · 위치 · 권한 요청 · 파일 고르기를 끄고, 섞인 내용(http)을 막는다.
|
||||
/// 글자 크기는 기기의 글자 크기 설정을 따른다(`setTextZoom` — WebView 는 저절로 따르지 않는다).
|
||||
/// - 쿠키 · 캐시: 문서가 바깥에 나가지 않아 쿠키를 받거나 보낼 일이 없고 내려받아 둘 것도 없다 — 따로 지우지 않는다.
|
||||
/// - 토큰은 WebView 에 가지 않는다(Bearer 는 Dio 만 — 그림 · 첨부는 앱이 받는다).
|
||||
///
|
||||
/// 높이: 머리(앱 화면) 아래 남은 자리를 다 쓰고 본문은 이 안에서 스크롤한다(앱 디자인 결정 — 스크립트가 꺼져 내용 높이를 잴 수 없다).
|
||||
class MailBodyView extends StatefulWidget {
|
||||
const MailBodyView({
|
||||
required this.document,
|
||||
required this.onNavigation,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// WebView 에 넣을 문서 — 바뀌면 다시 넣는다
|
||||
final String document;
|
||||
|
||||
/// 누른 링크의 판정 — 바깥 브라우저 · 쓰기 화면 · 무시(앱이 연다)
|
||||
final void Function(MailNavigation navigation) onNavigation;
|
||||
|
||||
@override
|
||||
State<MailBodyView> createState() => _MailBodyViewState();
|
||||
}
|
||||
|
||||
class _MailBodyViewState extends State<MailBodyView> {
|
||||
late final WebViewController _controller = WebViewController();
|
||||
late final Future<void> _configured = _configure();
|
||||
|
||||
/// 마지막으로 넣은 문서 — 같은 문서는 다시 넣지 않는다
|
||||
String? _loaded;
|
||||
int? _textZoom;
|
||||
Color? _background;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load(widget.document));
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final Color background = Theme.of(context).colorScheme.surface;
|
||||
if (background != _background) {
|
||||
_background = background;
|
||||
unawaited(
|
||||
_configured.then((_) => _controller.setBackgroundColor(background)),
|
||||
);
|
||||
}
|
||||
final int zoom = MediaQuery.textScalerOf(context).scale(_percent).round();
|
||||
if (zoom != _textZoom) {
|
||||
_textZoom = zoom;
|
||||
final AndroidWebViewController? android = _androidOf(_controller);
|
||||
if (android != null) {
|
||||
unawaited(_configured.then((_) => android.setTextZoom(zoom)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 안드로이드 WebView 면 그 컨트롤러 — 시험의 가짜 WebView · iOS 는 null
|
||||
static AndroidWebViewController? _androidOf(WebViewController controller) {
|
||||
final Object platform = controller.platform;
|
||||
return platform is AndroidWebViewController ? platform : null;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MailBodyView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.document != _loaded) unawaited(_load(widget.document));
|
||||
}
|
||||
|
||||
static const double _percent = 100;
|
||||
|
||||
Future<void> _configure() async {
|
||||
final WebViewController controller = _controller;
|
||||
await controller.setJavaScriptMode(JavaScriptMode.disabled);
|
||||
await controller.setNavigationDelegate(
|
||||
NavigationDelegate(onNavigationRequest: _onNavigationRequest),
|
||||
);
|
||||
final AndroidWebViewController? android = _androidOf(controller);
|
||||
if (android != null) {
|
||||
await android.setAllowFileAccess(false);
|
||||
await android.setAllowContentAccess(false);
|
||||
await android.setGeolocationEnabled(false);
|
||||
await android.setMediaPlaybackRequiresUserGesture(true);
|
||||
await android.setMixedContentMode(MixedContentMode.neverAllow);
|
||||
await android.setOnShowFileSelector(
|
||||
(FileSelectorParams _) async => const <String>[],
|
||||
);
|
||||
await android.setOnPlatformPermissionRequest(
|
||||
(PlatformWebViewPermissionRequest request) => unawaited(request.deny()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
NavigationDecision _onNavigationRequest(NavigationRequest request) {
|
||||
final MailNavigation navigation = mailNavigationOf(
|
||||
request.url,
|
||||
isMainFrame: request.isMainFrame,
|
||||
);
|
||||
if (navigation is OpenInitialDocument) return NavigationDecision.navigate;
|
||||
if (mounted) widget.onNavigation(navigation);
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
Future<void> _load(String document) async {
|
||||
_loaded = document;
|
||||
await _configured;
|
||||
// 기다리는 사이 더 새 문서가 왔으면 그것만 넣는다
|
||||
if (!mounted || _loaded != document) return;
|
||||
await _controller.loadHtmlString(document);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
container: true,
|
||||
label: MailReaderTexts.body,
|
||||
child: WebViewWidget(controller: _controller),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -59,12 +59,13 @@ class MailListEmpty extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 받지 못함 — 사전 문구 · 문의 코드 · [다시 시도](없는 메일함은 [받은편지함으로] — 다시 해도 같다)
|
||||
/// (웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다)
|
||||
/// (웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다). 읽기 화면도 쓴다(없는 메일은 [목록으로])
|
||||
class MailListError extends StatelessWidget {
|
||||
const MailListError({
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
this.onLeave,
|
||||
this.leaveLabel = MailListTexts.toInbox,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -74,6 +75,9 @@ class MailListError extends StatelessWidget {
|
||||
/// 없는 메일함(`RES_001`)일 때 갈 곳 — 없으면 늘 [다시 시도]
|
||||
final VoidCallback? onLeave;
|
||||
|
||||
/// [onLeave] 단추의 이름
|
||||
final String leaveLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
@@ -94,7 +98,7 @@ class MailListError extends StatelessWidget {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: notFound ? leave : onRetry,
|
||||
child: Text(notFound ? MailListTexts.toInbox : MailListTexts.retry),
|
||||
child: Text(notFound ? leaveLabel : MailListTexts.retry),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
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/message_detail.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/attachment_downloads.dart';
|
||||
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 첨부 목록 — "첨부 파일 N개" · 줄마다 파일 아이콘 · 이름 · 크기 · [받기] (보드 `App1Reader`).
|
||||
///
|
||||
/// 받는 동안은 크기 자리에 진행 막대 · "받는 중 64%" 이고 단추가 [받기 취소](✕)다.
|
||||
/// 대용량 첨부(11단계)는 첫 판에서 받지 않는다 — 이름 · 크기와 "대용량 첨부"만 보이고, 목록 아래에 받는 길을 알린다.
|
||||
class ReaderAttachments extends StatelessWidget {
|
||||
const ReaderAttachments({
|
||||
required this.attachments,
|
||||
required this.largeFiles,
|
||||
required this.downloads,
|
||||
required this.onDownload,
|
||||
required this.onCancel,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// 본문 안 그림을 뺀 첨부
|
||||
final List<MessageAttachment> attachments;
|
||||
final List<MessageLargeFile> largeFiles;
|
||||
|
||||
/// 받고 있는 첨부 — 첨부 id 마다
|
||||
final Map<String, AttachmentDownload> downloads;
|
||||
final ValueChanged<MessageAttachment> onDownload;
|
||||
final ValueChanged<MessageAttachment> onCancel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final int count = attachments.length + largeFiles.length;
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s12,
|
||||
),
|
||||
padding: const EdgeInsets.only(top: AppSpacing.s16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: extra.borderSubtle,
|
||||
width: AppSize.borderWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.s4),
|
||||
child: Semantics(
|
||||
header: true,
|
||||
child: Text(
|
||||
MailReaderTexts.attachmentsTitle(count),
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: AppTypography.semiBold,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final MessageAttachment attachment in attachments) ...<Widget>[
|
||||
const SizedBox(height: AppSpacing.s8),
|
||||
_AttachmentRow(
|
||||
key: ValueKey<String>('attachment-${attachment.id}'),
|
||||
attachment: attachment,
|
||||
download: downloads[attachment.id],
|
||||
onDownload: () => onDownload(attachment),
|
||||
onCancel: () => onCancel(attachment),
|
||||
),
|
||||
],
|
||||
for (final MessageLargeFile file in largeFiles) ...<Widget>[
|
||||
const SizedBox(height: AppSpacing.s8),
|
||||
_FileRow(
|
||||
icon: Icons.cloud_outlined,
|
||||
name: file.name,
|
||||
below: Text(
|
||||
'${formatFileSize(file.size)} · ${MailReaderTexts.largeFile}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: AppTypography.regular,
|
||||
color: extra.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (largeFiles.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s4,
|
||||
AppSpacing.s10,
|
||||
AppSpacing.s4,
|
||||
0,
|
||||
),
|
||||
child: KeepAllText(
|
||||
MailReaderTexts.largeFilesNote,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: extra.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AttachmentRow extends StatelessWidget {
|
||||
const _AttachmentRow({
|
||||
required this.attachment,
|
||||
required this.download,
|
||||
required this.onDownload,
|
||||
required this.onCancel,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final MessageAttachment attachment;
|
||||
|
||||
/// 받는 중이면 그 진행 — 아니면 null
|
||||
final AttachmentDownload? download;
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
static const List<FontFeature> _tabular = <FontFeature>[
|
||||
FontFeature.tabularFigures(),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final AttachmentDownload? progress = download;
|
||||
final Widget below = progress == null
|
||||
? Text(
|
||||
formatFileSize(attachment.size),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: AppTypography.regular,
|
||||
color: extra.textMuted,
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.xs),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress.fraction,
|
||||
minHeight: AppSize.progressBarHeight,
|
||||
backgroundColor: scheme.surfaceContainerHighest,
|
||||
color: scheme.primary,
|
||||
// 값은 진행 막대가 0 ~ 100 의 수로 읽힌다(Flutter 가 비율로 만든다)
|
||||
semanticsLabel: MailReaderTexts.downloading(
|
||||
attachment.name,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s8),
|
||||
ExcludeSemantics(
|
||||
child: Text(
|
||||
MailReaderTexts.progress(progress.percent),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: AppTypography.regular,
|
||||
color: scheme.onPrimaryContainer,
|
||||
fontFeatures: _tabular,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return _FileRow(
|
||||
icon: Icons.insert_drive_file_outlined,
|
||||
name: attachment.name,
|
||||
below: below,
|
||||
trailing: progress == null
|
||||
? IconButton(
|
||||
icon: const Icon(
|
||||
Icons.download_outlined,
|
||||
size: AppSize.iconMedium,
|
||||
),
|
||||
tooltip: MailReaderTexts.download(attachment.name),
|
||||
color: extra.textStrong,
|
||||
onPressed: onDownload,
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.close, size: AppSize.iconMedium),
|
||||
tooltip: MailReaderTexts.cancelDownload(attachment.name),
|
||||
color: extra.textStrong,
|
||||
onPressed: onCancel,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 파일 줄의 모양 — 테두리 상자 · 아이콘 칸(40) · 이름 · 아래 줄 · 오른쪽 단추
|
||||
class _FileRow extends StatelessWidget {
|
||||
const _FileRow({
|
||||
required this.icon,
|
||||
required this.name,
|
||||
required this.below,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String name;
|
||||
final Widget below;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final Widget? action = trailing;
|
||||
return Container(
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: AppSize.attachmentRowMinHeight,
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpacing.s12,
|
||||
AppSpacing.s8,
|
||||
action == null ? AppSpacing.s12 : AppSpacing.s4,
|
||||
AppSpacing.s8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: scheme.outlineVariant,
|
||||
width: AppSize.borderWidth,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
ExcludeSemantics(
|
||||
child: Container(
|
||||
width: AppSize.fileIconBox,
|
||||
height: AppSize.fileIconBox,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: AppSize.iconMedium,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: AppTypography.medium,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s4),
|
||||
below,
|
||||
],
|
||||
),
|
||||
),
|
||||
?action,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/core/theme/app_theme.dart';
|
||||
import 'package:arasystem_app/core/theme/app_typography.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 더 보기 메뉴의 항목
|
||||
enum ReaderMenuAction {
|
||||
/// [안 읽음 표시] · [읽음 표시]
|
||||
toggleRead,
|
||||
|
||||
/// [중요 표시] · [중요 해제]
|
||||
toggleFlag,
|
||||
|
||||
/// [이동]
|
||||
move,
|
||||
|
||||
/// [스팸 신고] · [스팸 아님]
|
||||
toggleSpam,
|
||||
}
|
||||
|
||||
/// 읽기 화면의 위 막대 — 뒤로 · (빈자리) · 삭제 · 더 보기 (보드 `App1Reader`, 높이 56)
|
||||
class ReaderAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const ReaderAppBar({
|
||||
required this.onBack,
|
||||
this.onDelete,
|
||||
this.permanentDelete = false,
|
||||
this.menu = const <ReaderMenuItem>[],
|
||||
this.onMenu,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final VoidCallback onBack;
|
||||
|
||||
/// [삭제] — null 이면 꺼진 단추(받는 중 · 하는 중)
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
/// 휴지통에만 든 메일 — [삭제]가 빨간 [영구 삭제]
|
||||
final bool permanentDelete;
|
||||
|
||||
/// 더 보기 메뉴 — 비면 단추를 그리지 않는다
|
||||
final List<ReaderMenuItem> menu;
|
||||
|
||||
/// 더 보기 메뉴에서 고른 것 — null 이면 꺼진 단추
|
||||
final ValueChanged<ReaderMenuAction>? onMenu;
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(AppSize.readerBarHeight);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final ValueChanged<ReaderMenuAction>? select = onMenu;
|
||||
return AppBar(
|
||||
toolbarHeight: AppSize.readerBarHeight,
|
||||
automaticallyImplyLeading: false,
|
||||
leadingWidth: AppSize.touchMin + AppSpacing.s4,
|
||||
titleSpacing: 0,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(left: AppSpacing.s4),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, size: AppSize.iconLarge),
|
||||
tooltip: MailReaderTexts.back,
|
||||
color: extra.textStrong,
|
||||
onPressed: onBack,
|
||||
),
|
||||
),
|
||||
actionsPadding: const EdgeInsets.only(right: AppSpacing.s4),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
permanentDelete
|
||||
? Icons.delete_forever_outlined
|
||||
: Icons.delete_outline,
|
||||
size: AppSize.iconToolbar,
|
||||
),
|
||||
tooltip: permanentDelete
|
||||
? MailReaderTexts.purge
|
||||
: MailReaderTexts.delete,
|
||||
color: permanentDelete ? scheme.error : extra.textStrong,
|
||||
onPressed: onDelete,
|
||||
),
|
||||
if (menu.isNotEmpty)
|
||||
PopupMenuButton<ReaderMenuAction>(
|
||||
icon: const Icon(Icons.more_vert, size: AppSize.iconToolbar),
|
||||
iconColor: extra.textStrong,
|
||||
tooltip: MailReaderTexts.more,
|
||||
enabled: select != null,
|
||||
position: PopupMenuPosition.under,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: AppSize.readerMenuWidth,
|
||||
),
|
||||
color: scheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
onSelected: select,
|
||||
itemBuilder: (BuildContext context) =>
|
||||
<PopupMenuEntry<ReaderMenuAction>>[
|
||||
for (final ReaderMenuItem item in menu)
|
||||
PopupMenuItem<ReaderMenuAction>(
|
||||
value: item.action,
|
||||
height: AppSize.touchMin,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
item.icon,
|
||||
size: AppSize.iconMedium,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.label,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 더 보기 메뉴의 한 줄
|
||||
@immutable
|
||||
class ReaderMenuItem {
|
||||
const ReaderMenuItem(this.action, this.label, this.icon);
|
||||
|
||||
final ReaderMenuAction action;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
}
|
||||
|
||||
/// 아래 행동 막대 — 답장 · 전체 답장 · 전달 (보드 `App1Reader`, 높이 64 · 위에 선, 화면 낭독기 이름 "답장과 전달").
|
||||
/// 단추마다 null 이면 꺼진다(삭제 · 이동 등을 하는 동안 — 끝나면 목록으로 돌아가므로 그 위에 쓰기 화면을 열지 않게)
|
||||
class ReaderActionBar extends StatelessWidget {
|
||||
const ReaderActionBar({
|
||||
required this.onReply,
|
||||
required this.onReplyAll,
|
||||
required this.onForward,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final VoidCallback? onReply;
|
||||
final VoidCallback? onReplyAll;
|
||||
final VoidCallback? onForward;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
return Semantics(
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
label: MailReaderTexts.actions,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
width: AppSize.borderWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
height: AppSize.readerActionBarHeight,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
_ActionButton(
|
||||
icon: Icons.reply,
|
||||
label: MailReaderTexts.reply,
|
||||
color: extra.textStrong,
|
||||
onPressed: onReply,
|
||||
),
|
||||
_ActionButton(
|
||||
icon: Icons.reply_all,
|
||||
label: MailReaderTexts.replyAll,
|
||||
color: extra.textStrong,
|
||||
onPressed: onReplyAll,
|
||||
),
|
||||
_ActionButton(
|
||||
icon: Icons.forward,
|
||||
label: MailReaderTexts.forward,
|
||||
color: extra.textStrong,
|
||||
onPressed: onForward,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionButton extends StatelessWidget {
|
||||
const _ActionButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
/// null 이면 꺼진다
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final VoidCallback? press = onPressed;
|
||||
final Color shown = press == null ? theme.disabledColor : color;
|
||||
return Expanded(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
enabled: press != null,
|
||||
label: label,
|
||||
excludeSemantics: true,
|
||||
onTap: press,
|
||||
child: InkWell(
|
||||
onTap: press,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(icon, size: AppSize.iconToolbar, color: shown),
|
||||
const SizedBox(height: AppSpacing.s2),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: AppTypography.semiBold,
|
||||
color: shown,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/core/theme/app_theme.dart';
|
||||
import 'package:arasystem_app/core/theme/app_typography.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/message_reader_view.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 읽기 화면의 머리 — 제목 · 보낸 사람(동그란 사진 40) · 시각 · "받는 사람 나 외 1명"(누르면 펼침) (보드 `App1Reader`).
|
||||
///
|
||||
/// 펼치면 회색 상자에 보낸 사람 · 받는 사람 · 참조 · 숨은참조(내가 보낸 메일) · 날짜를 칸마다 이름 · 주소로 보인다.
|
||||
class ReaderHeader extends StatefulWidget {
|
||||
const ReaderHeader({
|
||||
required this.detail,
|
||||
required this.myEmail,
|
||||
required this.now,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final MessageDetail detail;
|
||||
|
||||
/// 지금 직원의 주소 — 받는 사람 가운데 있으면 "나"
|
||||
final String? myEmail;
|
||||
|
||||
/// 시각 표기의 "오늘 · 어제"를 가르는 지금
|
||||
final DateTime now;
|
||||
|
||||
@override
|
||||
State<ReaderHeader> createState() => _ReaderHeaderState();
|
||||
}
|
||||
|
||||
class _ReaderHeaderState extends State<ReaderHeader> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final MessageDetail detail = widget.detail;
|
||||
final String subject = detail.summary.subject.isEmpty
|
||||
? MailReaderTexts.noSubject
|
||||
: detail.summary.subject;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s20,
|
||||
AppSpacing.s6,
|
||||
AppSpacing.s20,
|
||||
0,
|
||||
),
|
||||
child: Semantics(
|
||||
header: true,
|
||||
child: Text(
|
||||
subject,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: AppTypography.semiBold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s20,
|
||||
AppSpacing.s14,
|
||||
AppSpacing.s20,
|
||||
0,
|
||||
),
|
||||
child: _SenderRow(
|
||||
detail: detail,
|
||||
myEmail: widget.myEmail,
|
||||
now: widget.now,
|
||||
expanded: _expanded,
|
||||
onToggle: () => setState(() => _expanded = !_expanded),
|
||||
),
|
||||
),
|
||||
if (_expanded) _ReaderDetails(detail: detail),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SenderRow extends StatelessWidget {
|
||||
const _SenderRow({
|
||||
required this.detail,
|
||||
required this.myEmail,
|
||||
required this.now,
|
||||
required this.expanded,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
final MessageDetail detail;
|
||||
final String? myEmail;
|
||||
final DateTime now;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
static const List<FontFeature> _tabular = <FontFeature>[
|
||||
FontFeature.tabularFigures(),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final MailAddress? from = detail.summary.from;
|
||||
final String sender = from?.displayName ?? MailReaderTexts.noSender;
|
||||
final String? initial = from == null
|
||||
? null
|
||||
: initialOf(from.name, fallback: from.email);
|
||||
final String recipients = recipientSummaryOf(detail, myEmail: myEmail);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
ExcludeSemantics(child: _Avatar(initial: initial)),
|
||||
const SizedBox(width: AppSpacing.s12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
sender,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: AppTypography.semiBold,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s8),
|
||||
Text(
|
||||
formatReaderTime(detail.summary.receivedAt, now),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: AppTypography.regular,
|
||||
color: extra.textMuted,
|
||||
fontFeatures: _tabular,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Semantics(
|
||||
button: true,
|
||||
expanded: expanded,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
onTap: onToggle,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: AppSize.touchMin,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: Text(
|
||||
recipients,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s4),
|
||||
Icon(
|
||||
expanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: AppSize.iconTiny,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
const _Avatar({required this.initial});
|
||||
|
||||
final String? initial;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final String? letter = initial;
|
||||
return Container(
|
||||
width: AppSize.avatar,
|
||||
height: AppSize.avatar,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: scheme.primaryContainer,
|
||||
),
|
||||
child: letter == null
|
||||
? Icon(
|
||||
Icons.person_outline,
|
||||
size: AppSize.iconMedium,
|
||||
color: scheme.onPrimaryContainer,
|
||||
)
|
||||
: Text(
|
||||
letter,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: AppTypography.semiBold,
|
||||
color: scheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 머리 펼침 — 칸 이름(64) · 이름과 주소 (보드 `App1Reader` 「머리 펼침」)
|
||||
class _ReaderDetails extends StatelessWidget {
|
||||
const _ReaderDetails({required this.detail});
|
||||
|
||||
final MessageDetail detail;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final MailAddress? from = detail.summary.from;
|
||||
final List<Widget> rows = <Widget>[
|
||||
_DetailsRow(
|
||||
label: MailReaderTexts.fromLabel,
|
||||
lines: from == null
|
||||
? const <ReaderAddressLine>[
|
||||
(name: MailReaderTexts.noSender, email: null),
|
||||
]
|
||||
: <ReaderAddressLine>[readerAddressLine(from)],
|
||||
),
|
||||
if (detail.summary.to.isNotEmpty)
|
||||
_DetailsRow(
|
||||
label: MailReaderTexts.toLabel,
|
||||
lines: detail.summary.to.map(readerAddressLine).toList(),
|
||||
),
|
||||
if (detail.cc.isNotEmpty)
|
||||
_DetailsRow(
|
||||
label: MailReaderTexts.ccLabel,
|
||||
lines: detail.cc.map(readerAddressLine).toList(),
|
||||
),
|
||||
if (detail.bcc.isNotEmpty)
|
||||
_DetailsRow(
|
||||
label: MailReaderTexts.bccLabel,
|
||||
lines: detail.bcc.map(readerAddressLine).toList(),
|
||||
),
|
||||
_DetailsRow(
|
||||
label: MailReaderTexts.dateLabel,
|
||||
lines: <ReaderAddressLine>[
|
||||
(name: formatReaderFullDate(detail.summary.receivedAt), email: null),
|
||||
],
|
||||
),
|
||||
];
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s10,
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s4,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.s14,
|
||||
vertical: AppSpacing.s12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
for (int index = 0; index < rows.length; index++) ...<Widget>[
|
||||
if (index > 0) const SizedBox(height: AppSpacing.s8),
|
||||
rows[index],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailsRow extends StatelessWidget {
|
||||
const _DetailsRow({required this.label, required this.lines});
|
||||
|
||||
final String label;
|
||||
final List<ReaderAddressLine> lines;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final TextStyle? labelStyle = theme.textTheme.bodySmall?.copyWith(
|
||||
color: extra.textMuted,
|
||||
);
|
||||
final TextStyle? nameStyle = theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
);
|
||||
final TextStyle? emailStyle = theme.textTheme.bodySmall?.copyWith(
|
||||
color: extra.textMuted,
|
||||
);
|
||||
return MergeSemantics(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: AppSize.detailsLabelWidth,
|
||||
child: Text(label, style: labelStyle),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
for (final ReaderAddressLine line in lines) ...<Widget>[
|
||||
Text(line.name, style: nameStyle),
|
||||
if (line.email case final String email)
|
||||
Text(email, style: emailStyle),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/core/theme/app_theme.dart';
|
||||
import 'package:arasystem_app/core/theme/app_typography.dart';
|
||||
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/domain/mail_body_notices.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/message_reader_view.dart';
|
||||
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 머리 아래의 안내 줄 — 차례: 붙잡힌 메일(청록 — [보내기 취소] · [예약 취소]) → 본문 안내(생략 → 잘림) → 외부 이미지 차단(노랑 — [이미지 보기])
|
||||
/// (보드 `App1Reader` · 웹 `MessageReaderNotices` 와 같은 차례)
|
||||
class ReaderNotices extends StatelessWidget {
|
||||
const ReaderNotices({
|
||||
required this.detail,
|
||||
required this.htmlOmitted,
|
||||
required this.busy,
|
||||
required this.imagesLoading,
|
||||
required this.onShowImages,
|
||||
required this.onCancelHeld,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final MessageDetail detail;
|
||||
|
||||
/// 서식 본문을 보이지 않는다 — 서버가 비웠거나(`isHtmlBodyOmitted`) 앱의 정화 한도를 넘었다
|
||||
final bool htmlOmitted;
|
||||
|
||||
/// 하는 일이 있다 — 안내 줄의 단추를 막는다
|
||||
final bool busy;
|
||||
|
||||
/// [이미지 보기]로 다시 받는 중
|
||||
final bool imagesLoading;
|
||||
final VoidCallback onShowImages;
|
||||
final VoidCallback onCancelHeld;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final PendingSend? pending = detail.summary.pendingSend;
|
||||
final List<MailBodyNotice> bodyNotices = mailBodyNoticesOf(
|
||||
textBody: detail.textBody,
|
||||
isHtmlBodyOmitted: htmlOmitted,
|
||||
isBodyTruncated: detail.isBodyTruncated,
|
||||
);
|
||||
// 서식 본문을 보이지 않으면 외부 이미지도 없다 — [이미지 보기]를 두지 않는다
|
||||
final bool imagesBlocked =
|
||||
detail.hasBlockedImages && !detail.imagesShown && !htmlOmitted;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
if (pending != null)
|
||||
ReaderNotice(
|
||||
key: const ValueKey<String>('notice-held'),
|
||||
tone: ReaderNoticeTone.brand,
|
||||
icon: Icons.schedule,
|
||||
text: pendingNoticeOf(pending),
|
||||
actionLabel: pending.kind == PendingSendKind.scheduled
|
||||
? MailReaderTexts.cancelSchedule
|
||||
: MailReaderTexts.undoSend,
|
||||
onAction: busy ? null : onCancelHeld,
|
||||
),
|
||||
for (final MailBodyNotice notice in bodyNotices)
|
||||
ReaderNotice(
|
||||
key: ValueKey<String>('notice-${notice.name}'),
|
||||
tone: ReaderNoticeTone.warning,
|
||||
icon: Icons.error_outline,
|
||||
text: MailReaderTexts.bodyNotice(notice),
|
||||
),
|
||||
if (imagesBlocked)
|
||||
ReaderNotice(
|
||||
key: const ValueKey<String>('notice-images'),
|
||||
tone: ReaderNoticeTone.warning,
|
||||
icon: Icons.hide_image_outlined,
|
||||
text: MailReaderTexts.imagesBlocked,
|
||||
actionLabel: MailReaderTexts.showImages,
|
||||
onAction: imagesLoading ? null : onShowImages,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 안내 줄의 색
|
||||
enum ReaderNoticeTone {
|
||||
/// 노랑 — 본문 안내 · 외부 이미지 차단
|
||||
warning,
|
||||
|
||||
/// 청록 — 붙잡힌 메일
|
||||
brand,
|
||||
}
|
||||
|
||||
/// 안내 줄 하나 — 아이콘 · 글, 단추는 오른쪽 아래(누르는 곳 48 — 보드 `App1Reader` 의 [이미지 보기])
|
||||
class ReaderNotice extends StatelessWidget {
|
||||
const ReaderNotice({
|
||||
required this.tone,
|
||||
required this.icon,
|
||||
required this.text,
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ReaderNoticeTone tone;
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
/// 단추 이름 — 없으면 단추 없는 줄
|
||||
final String? actionLabel;
|
||||
|
||||
/// 단추 — null 이면 꺼진 단추(하는 중)
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final (Color background, Color border, Color foreground) = switch (tone) {
|
||||
ReaderNoticeTone.warning => (
|
||||
extra.warningSurface,
|
||||
extra.warningBorder,
|
||||
extra.warningText,
|
||||
),
|
||||
ReaderNoticeTone.brand => (
|
||||
scheme.primaryContainer,
|
||||
extra.brandTintBorder,
|
||||
scheme.onPrimaryContainer,
|
||||
),
|
||||
};
|
||||
final String? label = actionLabel;
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s12,
|
||||
AppSpacing.s16,
|
||||
0,
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpacing.s14,
|
||||
AppSpacing.s12,
|
||||
label == null ? AppSpacing.s14 : AppSpacing.s6,
|
||||
label == null ? AppSpacing.s12 : AppSpacing.s2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
border: Border.all(color: border, width: AppSize.borderWidth),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.s2),
|
||||
child: Icon(icon, size: AppSize.iconSmall, color: foreground),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s10),
|
||||
Expanded(
|
||||
child: KeepAllText(
|
||||
text,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: foreground,
|
||||
height: AppTypography.lineHeightRelaxed,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (label != null)
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: foreground,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.s12,
|
||||
),
|
||||
textStyle: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: AppTypography.bold,
|
||||
),
|
||||
),
|
||||
onPressed: onAction,
|
||||
child: Text(label),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/app.dart';
|
||||
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/storage/first_run_marker.dart';
|
||||
import 'package:arasystem_app/core/storage/secure_store.dart';
|
||||
import 'package:arasystem_app/core/storage/token_store.dart';
|
||||
@@ -38,6 +41,10 @@ Future<void> main() async {
|
||||
final ({String? appVersion, String userAgent}) device =
|
||||
await loadAppDeviceInfo();
|
||||
|
||||
// 지난 실행이 받아 둔 파일(메일 첨부)을 지운다 — 기다리지 않는다(저장소가 차례를 지켜 새로 받는 파일은 지우지 않는다)
|
||||
final DownloadedFileStore downloadedFiles = CacheDirFileStore();
|
||||
unawaited(downloadedFiles.purge());
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
// 프로바이더의 실패를 저절로 다시 하지 않는다(core/utils/provider_retry.dart)
|
||||
@@ -46,6 +53,7 @@ Future<void> main() async {
|
||||
tokenStoreProvider.overrideWithValue(tokenStore),
|
||||
userAgentProvider.overrideWithValue(device.userAgent),
|
||||
appVersionProvider.overrideWithValue(device.appVersion),
|
||||
downloadedFileStoreProvider.overrideWithValue(downloadedFiles),
|
||||
],
|
||||
child: const App(),
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import flutter_secure_storage_darwin
|
||||
import package_info_plus
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
import webview_flutter_wkwebview
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
@@ -17,4 +18,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
|
||||
}
|
||||
|
||||
+50
-2
@@ -105,6 +105,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -301,6 +309,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
html:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: html
|
||||
sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.7"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -422,7 +438,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
@@ -486,7 +502,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
@@ -882,6 +898,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
webview_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: webview_flutter
|
||||
sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.14.1"
|
||||
webview_flutter_android:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: webview_flutter_android
|
||||
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.0"
|
||||
webview_flutter_platform_interface:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: webview_flutter_platform_interface
|
||||
sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.15.1"
|
||||
webview_flutter_wkwebview:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_wkwebview
|
||||
sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.25.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -29,20 +29,36 @@ dependencies:
|
||||
flutter_secure_storage: ^10.0.0
|
||||
# 화면 이동 — redirect 하나가 세션 상태로 화면을 고른다 (core/router/)
|
||||
go_router: ^17.5.0
|
||||
# 받은 메일 본문의 두 번째 정화 — HTML5 파서로 읽어 허용 목록만 남긴다 (features/mail/domain/mail_html.dart).
|
||||
# 0.15.7 은 2026-08-28 배포(Dart 팀 tools.dart.dev). 따라오는 csslib 1.0.2 도 같은 곳
|
||||
html: ^0.15.7
|
||||
# 날짜 · 숫자 형식 — flutter_localizations 가 이 판으로 고정한다
|
||||
intl: 0.20.2
|
||||
# 받은 첨부를 열 형식 — 확장자 → MIME 표(core/files/file_types.dart). 이미 lock 에 따라 들어와 있던 2.0.0(2024-09-30 배포, Dart 팀) 그대로
|
||||
mime: ^2.0.0
|
||||
# 앱 판 — User-Agent 만들기
|
||||
package_info_plus: ^10.2.2
|
||||
# 받은 첨부를 둘 앱 임시 폴더(Android cacheDir) — 이미 shared_preferences 를 따라 들어와 있던 판이다 (core/files/)
|
||||
path_provider: ^2.1.5
|
||||
# 첫 실행 표시 하나만 둔다 — 토큰은 여기에 두지 않는다 (core/storage/first_run_marker.dart)
|
||||
shared_preferences: ^2.5.6
|
||||
# 다른 앱 열기 — 로그인 2단계 켜기의 [인증 앱에 추가](otpauth 주소 — features/auth/data/authenticator_launcher.dart).
|
||||
# 6.3.3 은 2026-10-02 배포(Flutter 팀). canLaunchUrl 은 쓰지 않는다 — debug 판 안드로이드가 주소째 로그에 남긴다
|
||||
url_launcher: ^6.3.3
|
||||
# 받은 메일 본문 — 스크립트를 끈 WebView 한 겹(features/mail/presentation/widgets/mail_body_view.dart).
|
||||
# 4.14.1 은 2026-07-07 배포(Flutter 팀). Dart 3.11 · Flutter 3.41 에 맞는 하위 판은
|
||||
# webview_flutter_android 4.12.0(2026-04-29) · _wkwebview 3.25.1(2026-04-29) · _platform_interface 2.15.1(2026-03-25) 이다
|
||||
# (그 위 판은 Dart 3.12 · Flutter 3.44 가 든다)
|
||||
webview_flutter: ^4.14.1
|
||||
# 안드로이드 WebView 의 파일 · content 접근 끄기 · 글자 크기 — 위 webview_flutter 가 고른 판 그대로
|
||||
webview_flutter_android: ^4.12.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
# 시험의 가짜 WebView(test/support/fake_webview.dart) — webview_flutter 가 고른 판 그대로(새로 받는 것 없음)
|
||||
webview_flutter_platform_interface: ^2.15.1
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:arasystem_app/features/auth/presentation/pages/login_page.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/pages/mail_list_page.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
|
||||
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -181,7 +182,13 @@ void main() {
|
||||
router.go(MailRoutes.message('m1'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(page(MailTexts.message), findsOneWidget);
|
||||
expect(find.byType(MessageReaderPage), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
|
||||
router.go(MailRoutes.compose);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(page(MailTexts.compose), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:arasystem_app/core/files/downloaded_files.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 받은 파일 보관(`CacheDirFileStore`) — 시험마다 만든 임시 폴더를 앱 임시 폴더로 쓴다(실제 파일 입출력)
|
||||
void main() {
|
||||
late Directory base;
|
||||
late CacheDirFileStore store;
|
||||
|
||||
setUp(() async {
|
||||
base = await Directory.systemTemp.createTemp('arasystem-files-test-');
|
||||
store = CacheDirFileStore(
|
||||
baseDirectory: () async => base,
|
||||
random: Random(7),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (await base.exists()) await base.delete(recursive: true);
|
||||
});
|
||||
|
||||
String rootPath() => '${base.path}/$kDownloadedFilesFolder';
|
||||
|
||||
test('받을 때마다 받은 파일 폴더 아래 새 폴더 한 칸 · 닫으면 조각이 모두 쓰인다', () async {
|
||||
final DownloadTarget first = await store.create('견적서.pdf');
|
||||
first
|
||||
..add(<int>[1, 2, 3])
|
||||
..add(<int>[4]);
|
||||
await first.close();
|
||||
final DownloadTarget second = await store.create('견적서.pdf');
|
||||
await second.close();
|
||||
|
||||
expect(await File(first.path).readAsBytes(), <int>[1, 2, 3, 4]);
|
||||
expect(second.path, isNot(first.path));
|
||||
for (final DownloadTarget target in <DownloadTarget>[first, second]) {
|
||||
expect(target.path, startsWith('${rootPath()}/'));
|
||||
// 폴더 한 칸 + 이름 — 이름이 경로를 벗어나지 않는다
|
||||
expect(target.path.substring(rootPath().length + 1).split('/'), <Object>[
|
||||
hasLength(16),
|
||||
'견적서.pdf',
|
||||
]);
|
||||
}
|
||||
expect(await store.exists(first.path), isTrue);
|
||||
});
|
||||
|
||||
test('보낸 쪽이 정한 이름의 경로 문자는 밑줄 — 받은 파일 폴더를 벗어나지 않는다', () async {
|
||||
final DownloadTarget target = await store.create('../../etc/passwd');
|
||||
await target.close();
|
||||
expect(
|
||||
target.path.substring(rootPath().length + 1).split('/'),
|
||||
hasLength(2),
|
||||
);
|
||||
expect(target.path, endsWith('/_.._etc_passwd'));
|
||||
});
|
||||
|
||||
test('받다 말면(discard) 쓰다 만 파일을 폴더째 지운다', () async {
|
||||
final DownloadTarget target = await store.create('a.bin');
|
||||
target.add(<int>[1, 2]);
|
||||
await target.discard();
|
||||
expect(await File(target.path).exists(), isFalse);
|
||||
expect(await File(target.path).parent.exists(), isFalse);
|
||||
});
|
||||
|
||||
test('purge — 받은 파일 폴더를 모두 지운다(없어도 된다)', () async {
|
||||
final DownloadTarget target = await store.create('a.bin');
|
||||
await target.close();
|
||||
await store.purge();
|
||||
expect(await store.exists(target.path), isFalse);
|
||||
expect(await Directory(rootPath()).exists(), isFalse);
|
||||
await store.purge();
|
||||
});
|
||||
|
||||
test(
|
||||
'임시 폴더를 모르면 자리 만들기는 FileSystemException(저장하지 못함), 정리는 조용히 넘어간다',
|
||||
() async {
|
||||
final CacheDirFileStore broken = CacheDirFileStore(
|
||||
baseDirectory: () async => throw MissingPluginException('시험'),
|
||||
);
|
||||
await expectLater(
|
||||
broken.create('a.bin'),
|
||||
throwsA(isA<FileSystemException>()),
|
||||
);
|
||||
await broken.purge();
|
||||
// 실패한 뒤에도 다음 일은 차례대로 돈다
|
||||
await expectLater(
|
||||
broken.create('b.bin'),
|
||||
throwsA(isA<FileSystemException>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:arasystem_app/core/files/file_types.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
|
||||
/// 받은 파일을 열 형식 · 앱에서 열지 않는 형식 (`core/files/file_types.dart`)
|
||||
void main() {
|
||||
group('열 형식 — 확장자에서 먼저', () {
|
||||
test('확장자를 알면 보낸 쪽 형식보다 확장자의 형식', () {
|
||||
expect(fileOpenTypeOf('견적서.pdf', 'image/png'), 'application/pdf');
|
||||
expect(fileOpenTypeOf('사진.JPG', 'text/plain'), 'image/jpeg');
|
||||
expect(
|
||||
fileOpenTypeOf('계약서.docx', 'application/octet-stream'),
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
);
|
||||
});
|
||||
|
||||
test('확장자를 모르면 서버 형식 — 소문자로, 매개변수는 떼고', () {
|
||||
expect(
|
||||
fileOpenTypeOf('보고서.hwp', 'application/x-hwp'),
|
||||
'application/x-hwp',
|
||||
);
|
||||
expect(fileOpenTypeOf('메모', 'Text/Plain; charset=UTF-8'), 'text/plain');
|
||||
});
|
||||
|
||||
test('확장자도 서버 형식도 쓸 수 없으면 아무 형식(octet-stream)', () {
|
||||
expect(fileOpenTypeOf('파일', null), kGenericFileType);
|
||||
expect(fileOpenTypeOf('파일', ''), kGenericFileType);
|
||||
expect(fileOpenTypeOf('파일.zzz', 'not a type'), kGenericFileType);
|
||||
expect(fileOpenTypeOf('파일.zzz', 'text/plain<x>'), kGenericFileType);
|
||||
});
|
||||
|
||||
test('확장자는 정리한 이름에서 본다 — 끝의 점 · 공백 · 제어 글자를 지운 뒤', () {
|
||||
expect(fileExtensionOf('보고서.PDF. '), 'pdf');
|
||||
expect(fileExtensionOf('a.htm\u0000'), 'htm');
|
||||
expect(fileExtensionOf('.bashrc'), '');
|
||||
expect(fileExtensionOf('이름'), '');
|
||||
});
|
||||
});
|
||||
|
||||
group('앱에서 열지 않는 형식', () {
|
||||
test('확장자로 — 웹 문서 · SVG · 설치 파일 · 인증서 · 키 묶음 (대소문자 · 끝 점과 상관없이)', () {
|
||||
for (final String extension in kBlockedOpenExtensions) {
|
||||
expect(
|
||||
isBlockedFileOpen('파일.$extension', 'application/pdf'),
|
||||
isTrue,
|
||||
reason: extension,
|
||||
);
|
||||
}
|
||||
expect(isBlockedFileOpen('안내.HTML', null), isTrue);
|
||||
expect(isBlockedFileOpen('안내.html.', 'text/plain'), isTrue);
|
||||
expect(isBlockedFileOpen('설치.Apk', null), isTrue);
|
||||
});
|
||||
|
||||
test('확장자에서 나오는 형식은 모두 막는 형식에 든다(package:mime 의 표가 바뀌면 여기서 알린다)', () {
|
||||
for (final String extension in kBlockedOpenExtensions) {
|
||||
final String? type = lookupMimeType('file.$extension');
|
||||
if (type == null) continue;
|
||||
expect(kBlockedOpenTypes, contains(type), reason: extension);
|
||||
}
|
||||
});
|
||||
|
||||
test('서버 형식으로 — 확장자가 무해해도 보낸 쪽이 웹 문서 · 설치 파일이라고 하면', () {
|
||||
expect(isBlockedFileOpen('보고서.pdf', 'text/html; charset=utf-8'), isTrue);
|
||||
expect(isBlockedFileOpen('그림.png', 'IMAGE/SVG+XML'), isTrue);
|
||||
expect(
|
||||
isBlockedFileOpen('앱', 'application/vnd.android.package-archive'),
|
||||
isTrue,
|
||||
);
|
||||
expect(isBlockedFileOpen('저장한 쪽', 'multipart/related'), isTrue);
|
||||
});
|
||||
|
||||
test('흔한 첨부는 받는다 — 문서 · 그림 · 압축 · 전달된 메일', () {
|
||||
expect(isBlockedFileOpen('견적서.pdf', 'application/pdf'), isFalse);
|
||||
expect(isBlockedFileOpen('사진.jpg', 'image/jpeg'), isFalse);
|
||||
expect(isBlockedFileOpen('자료.zip', 'application/zip'), isFalse);
|
||||
expect(isBlockedFileOpen('보고서.hwp', 'application/x-hwp'), isFalse);
|
||||
expect(isBlockedFileOpen('원본 메일.eml', 'message/rfc822'), isFalse);
|
||||
expect(isBlockedFileOpen('이름 없음', null), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:arasystem_app/features/mail/domain/mail_html.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_images.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// PNG 머리 — 그림 크기만 큰 시험 그림을 만든다
|
||||
const List<int> kTestPng = <int>[
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, //
|
||||
];
|
||||
|
||||
/// 받은 메일 본문의 두 번째 정화 · WebView 문서 (웹 `mail-html.spec.ts` 의 결)
|
||||
void main() {
|
||||
String clean(String html, {bool imagesShown = false}) =>
|
||||
sanitizeMailHtml(html, imagesShown: imagesShown).fragment;
|
||||
|
||||
group('정화 — 태그', () {
|
||||
test('스크립트 · 스타일 · 틀 · 개체 · SVG · 수식은 내용째 버린다', () {
|
||||
final String out = clean(
|
||||
'<p>안녕</p><script>alert(1)</script><style>p{color:red}</style>'
|
||||
'<iframe src="https://evil.example"></iframe><object data="x"></object>'
|
||||
'<embed src="x"><svg><script>alert(2)</script><text>svg 글</text></svg>'
|
||||
'<math><mi>x</mi></math><noscript><img src=x onerror=alert(3)></noscript>'
|
||||
'<template><b>숨은 글</b></template>',
|
||||
);
|
||||
expect(out, '<p>안녕</p>');
|
||||
});
|
||||
|
||||
test('폼 · 단추 · 모르는 태그는 태그만 지우고 글은 남긴다', () {
|
||||
final String out = clean(
|
||||
'<form action="https://evil.example"><button>보내기</button><o:p>본문</o:p></form>'
|
||||
'<input value="숨은 값"><select><option>항목</option></select>',
|
||||
);
|
||||
expect(out, '보내기본문');
|
||||
});
|
||||
|
||||
test('글 · 표 · 목록 · 인용은 남긴다', () {
|
||||
final String out = clean(
|
||||
'<blockquote><table><tbody><tr><td colspan="2">칸</td></tr></tbody></table>'
|
||||
'<ul><li>하나</li></ul><br><hr></blockquote>',
|
||||
);
|
||||
expect(
|
||||
out,
|
||||
'<blockquote><table><tbody><tr><td colspan="2">칸</td></tr></tbody></table>'
|
||||
'<ul><li>하나</li></ul><br><hr></blockquote>',
|
||||
);
|
||||
});
|
||||
|
||||
test('주석은 지우고 글은 이스케이프한다', () {
|
||||
expect(
|
||||
clean('<p><!-- 주석 -->a <b> & c</p>'),
|
||||
'<p>a <b> & c</p>',
|
||||
);
|
||||
});
|
||||
|
||||
test('깊은 중첩도 스택이 넘치지 않고 끝난다(되풀이로 돈다)', () {
|
||||
const int depth = 1500;
|
||||
final String html = '${'<div>' * depth}끝${'</div>' * depth}';
|
||||
final String out = clean(html);
|
||||
expect(out, '${'<div>' * depth}끝${'</div>' * depth}');
|
||||
});
|
||||
|
||||
test('맨 위 요소가 아주 많아도 문서로 읽어 고르게 끝난다', () {
|
||||
final String html = '<span>a</span>' * 40000;
|
||||
final String out = clean(html);
|
||||
expect(out.length, html.length);
|
||||
});
|
||||
});
|
||||
|
||||
group('정화 전 비용 (중첩)', () {
|
||||
test('태그마다 그때의 깊이를 더한다 — 빈 요소만 깊이를 늘리지 않는다', () {
|
||||
expect(htmlNestingCost('<p>a</p>', limit: 1000), 0 + 1);
|
||||
expect(
|
||||
htmlNestingCost('<div><p>a</p></div>', limit: 1000),
|
||||
0 + 1 + 2 + 1,
|
||||
);
|
||||
expect(
|
||||
htmlNestingCost('<div><br><img src="x"><br/></div>', limit: 1000),
|
||||
0 + 1 + 1 + 1 + 1,
|
||||
);
|
||||
expect(htmlNestingCost('글만 있다 <div>', limit: 1000), 0);
|
||||
});
|
||||
|
||||
test('빈 요소가 아닌 <x/> 는 여는 태그로 센다 — 파서가 / 를 무시한다', () {
|
||||
const int depth = 100;
|
||||
expect(
|
||||
htmlNestingCost('<div/>' * depth, limit: 1 << 30),
|
||||
htmlNestingCost('<div>' * depth, limit: 1 << 30),
|
||||
);
|
||||
expect(
|
||||
htmlNestingCost('<div><span/></div>', limit: 1000),
|
||||
// span 이 열린 채라 </div> 는 깊이 2 에서 세고 닫지 않는다(맨 위가 span)
|
||||
0 + 1 + 2,
|
||||
);
|
||||
});
|
||||
|
||||
test('주석을 건너뛰지 않는다 — 주석 안의 태그도 센다(서버가 주석을 지운다)', () {
|
||||
expect(
|
||||
htmlNestingCost('<!-- <div><div> --><p>a</p>', limit: 1000),
|
||||
0 + 1 + 2 + 3,
|
||||
);
|
||||
expect(htmlNestingCost('<!--><div><div>', limit: 1000), 0 + 1);
|
||||
expect(htmlNestingCost('<!-- x --!><div><div>', limit: 1000), 0 + 1);
|
||||
const int depth = 300;
|
||||
final String hidden = '<!--${'<div>' * depth}-->';
|
||||
expect(
|
||||
htmlNestingCost(hidden, limit: 1 << 30),
|
||||
htmlNestingCost('<div>' * depth, limit: 1 << 30),
|
||||
);
|
||||
// 문서 형식 선언 · 엉뚱한 주석은 파서처럼 다음 > 까지 건너뛴다
|
||||
expect(
|
||||
htmlNestingCost('<!DOCTYPE html><?x?><div><div>', limit: 1000),
|
||||
0 + 1,
|
||||
);
|
||||
});
|
||||
|
||||
test('맞지 않는 닫는 태그는 깊이를 줄이지 않는다 — 파서가 무시하는 </span> 으로 깊이를 덜 세지 않게', () {
|
||||
const int n = 1000;
|
||||
final String html = '${'<div>' * n}${'</span>' * n}${'<div>' * n}';
|
||||
// 여는 div 2,000 개는 0 ~ 1,999 깊이에서, </span> 1,000 개는 깊이 1,000 에서 센다
|
||||
const int expected = (2 * n - 1) * (2 * n) ~/ 2 + n * n;
|
||||
expect(htmlNestingCost(html, limit: 1 << 30), expected);
|
||||
// 맞는 닫는 태그는 줄인다 — 같은 깊이를 다시 연다
|
||||
expect(
|
||||
htmlNestingCost('<div></div><div></div>', limit: 1000),
|
||||
0 + 1 + 0 + 1,
|
||||
);
|
||||
expect(
|
||||
htmlNestingCost('<DIV><P>a</p></Div>', limit: 1000),
|
||||
0 + 1 + 2 + 1,
|
||||
);
|
||||
});
|
||||
|
||||
test('한도를 넘으면 그 자리에서 멈춘다 — 깊은 본문은 정화하지 않고 글자 본문을 보인다', () {
|
||||
const int depth = 20000;
|
||||
final String deep = '${'<div>' * depth}끝${'</div>' * depth}';
|
||||
expect(
|
||||
htmlNestingCost(deep, limit: kMaxHtmlNestingCost),
|
||||
greaterThan(kMaxHtmlNestingCost),
|
||||
);
|
||||
final SanitizedMailBody body = mailBodyFragment(
|
||||
htmlBody: deep,
|
||||
textBody: '글자 본문',
|
||||
imagesShown: true,
|
||||
);
|
||||
expect(body.htmlOmitted, isTrue);
|
||||
expect(body.fragment, '<div class="mail-text">글자 본문</div>');
|
||||
expect(body.proxiedTokens, isEmpty);
|
||||
});
|
||||
|
||||
test('서버 한도(2,000,000자)보다 긴 서식 본문도 글자 본문으로', () {
|
||||
final SanitizedMailBody body = mailBodyFragment(
|
||||
htmlBody: '<p>${'a' * kMaxSanitizedHtmlLength}</p>',
|
||||
textBody: null,
|
||||
imagesShown: false,
|
||||
);
|
||||
expect(body.htmlOmitted, isTrue);
|
||||
expect(body.fragment, '<div class="mail-text"></div>');
|
||||
});
|
||||
});
|
||||
|
||||
group('정화 — 속성', () {
|
||||
test('이벤트 처리기 · data-* · id · target · 외부 자원 속성은 지운다', () {
|
||||
final String out = clean(
|
||||
'<div id="x" onclick="alert(1)" data-ara-large-files="1" style="color:#123456" '
|
||||
'background="https://t.example/b.png"><a href="https://ok.example" target="_blank" '
|
||||
'rel="opener" ping="https://t.example">링크</a></div>',
|
||||
);
|
||||
expect(
|
||||
out,
|
||||
'<div style="color:#123456"><a href="https://ok.example">링크</a></div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('링크는 http · https · mailto 만 남긴다', () {
|
||||
expect(clean('<a href="javascript:alert(1)">a</a>'), '<a>a</a>');
|
||||
expect(clean('<a href=" JavaScript:alert(1)">a</a>'), '<a>a</a>');
|
||||
expect(clean('<a href="/api/messages">a</a>'), '<a>a</a>');
|
||||
expect(clean('<a href="file:///etc/passwd">a</a>'), '<a>a</a>');
|
||||
expect(clean('<a href="intent://x#Intent;end">a</a>'), '<a>a</a>');
|
||||
expect(
|
||||
clean('<a href="mailto:kim@example.com">a</a>'),
|
||||
'<a href="mailto:kim@example.com">a</a>',
|
||||
);
|
||||
expect(
|
||||
clean('<a href="HTTPS://Example.com/a?b=1&c=2">a</a>'),
|
||||
'<a href="HTTPS://Example.com/a?b=1&c=2">a</a>',
|
||||
);
|
||||
});
|
||||
|
||||
test('속성 값의 따옴표 · 꺾쇠는 늘 이스케이프한다', () {
|
||||
expect(
|
||||
clean('<span title="a"b>c<d" style="x">t</span>'),
|
||||
'<span title="a"b>c<d" style="x">t</span>',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'그림은 data: 그림(PNG · JPEG · GIF · WebP)만 — SVG · 바깥 주소 · 상대 주소는 주소를 지운다',
|
||||
() {
|
||||
const String png = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
expect(clean('<img src="$png" alt="서명">'), '<img src="$png" alt="서명">');
|
||||
expect(
|
||||
clean('<img src="data:image/svg+xml;base64,PHN2Zz4=">'),
|
||||
'<img>',
|
||||
);
|
||||
expect(
|
||||
clean('<img src="https://t.example/p.gif" width="1">'),
|
||||
'<img width="1">',
|
||||
);
|
||||
expect(clean('<img src="/api/mail-images/abc">'), '<img>');
|
||||
expect(clean('<img srcset="https://t.example/a.png 2x">'), '<img>');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('외부 이미지(중계 주소)', () {
|
||||
test('외부 이미지를 보일 때만 중계 주소를 남기고 표를 문서 차례 · 겹치지 않게 모은다', () {
|
||||
final SanitizedMailBody body = sanitizeMailHtml(
|
||||
'<img src="/api/mail-images/tok_A" alt="a"><img src="/api/mail-images/tok-B">'
|
||||
'<img src="/api/mail-images/tok_A"><img src="/api/mail-images/../x">'
|
||||
'<img src="https://t.example/p.png">',
|
||||
imagesShown: true,
|
||||
);
|
||||
expect(body.proxiedTokens, <String>['tok_A', 'tok-B']);
|
||||
expect(
|
||||
body.fragment,
|
||||
'<img src="/api/mail-images/tok_A" alt="a"><img src="/api/mail-images/tok-B">'
|
||||
'<img src="/api/mail-images/tok_A"><img><img>',
|
||||
);
|
||||
});
|
||||
|
||||
test('한 통에 100개까지만 받는다 — 넘는 그림은 주소를 지운다', () {
|
||||
final String html = <String>[
|
||||
for (int n = 0; n < kMaxProxiedImages + 5; n++)
|
||||
'<img src="/api/mail-images/t$n">',
|
||||
].join();
|
||||
final SanitizedMailBody body = sanitizeMailHtml(html, imagesShown: true);
|
||||
expect(body.proxiedTokens, hasLength(kMaxProxiedImages));
|
||||
expect(body.fragment, endsWith('<img><img><img><img><img>'));
|
||||
});
|
||||
|
||||
test('받은 그림으로 바꾸고, 받지 못한 그림은 주소를 지운다 — 글 속의 같은 모양에는 닿지 않는다', () {
|
||||
final SanitizedMailBody body = sanitizeMailHtml(
|
||||
'<p><img src="/api/mail-images/t1"</p>'
|
||||
'<img src="/api/mail-images/t1" alt="x"><img src="/api/mail-images/t2">',
|
||||
imagesShown: true,
|
||||
);
|
||||
final String resolved = resolveProxiedImages(
|
||||
body.fragment,
|
||||
<String, String>{'t1': 'data:image/png;base64,AAAA'},
|
||||
);
|
||||
expect(
|
||||
resolved,
|
||||
'<p><img src="/api/mail-images/t1"</p>'
|
||||
'<img src="data:image/png;base64,AAAA" alt="x"><img>',
|
||||
);
|
||||
});
|
||||
|
||||
/// [token] 을 [count] 번 쓴 본문의 정화 조각
|
||||
String repeated(String token, int count) => sanitizeMailHtml(
|
||||
'<img src="/api/mail-images/$token">' * count,
|
||||
imagesShown: true,
|
||||
).fragment;
|
||||
|
||||
/// 실린 그림 수 · 지운 그림 수
|
||||
({int shown, int hidden}) countImages(String resolved) => (
|
||||
shown: '<img src="data:'.allMatches(resolved).length,
|
||||
hidden: '<img>'.allMatches(resolved).length,
|
||||
);
|
||||
|
||||
test(
|
||||
'같은 그림이 여러 번 나오면 나온 만큼 센다 — 1MB 그림 1,000번도 문서가 한도 안이고 앞쪽 몇 개만 보인다',
|
||||
() {
|
||||
final Uint8List bytes = Uint8List(1024 * 1024)
|
||||
..setRange(0, kTestPng.length, kTestPng);
|
||||
final String data = imageDataUri('image/png', bytes);
|
||||
final String fragment = repeated('big', 1000);
|
||||
|
||||
final String resolved = resolveProxiedImages(fragment, <String, String>{
|
||||
'big': data,
|
||||
});
|
||||
|
||||
final int fits = kMailImagesDocumentMaxLength ~/ data.length;
|
||||
expect(fits, 7);
|
||||
expect(countImages(resolved), (shown: fits, hidden: 1000 - fits));
|
||||
expect(
|
||||
resolved.length,
|
||||
lessThanOrEqualTo(kMailImagesDocumentMaxLength + fragment.length),
|
||||
);
|
||||
// 앞쪽부터 싣는다
|
||||
expect(resolved, startsWith('<img src="data:image/png;base64,'));
|
||||
expect(resolved, endsWith('<img><img>'));
|
||||
},
|
||||
);
|
||||
|
||||
test('1KB 아이콘이 같은 표로 300번 나오면 모두 보인다', () {
|
||||
final String icon = 'data:image/png;base64,${'A' * 1024}';
|
||||
final String resolved = resolveProxiedImages(
|
||||
repeated('icon', 300),
|
||||
<String, String>{'icon': icon},
|
||||
);
|
||||
expect(countImages(resolved), (shown: 300, hidden: 0));
|
||||
});
|
||||
|
||||
test(
|
||||
'서로 다른 그림이 섞이면 문서 차례로 싣고, 넘는 자리부터는 뒤의 작은 그림도 지운다 — 받지 못한 그림은 세지 않는다',
|
||||
() {
|
||||
final SanitizedMailBody body = sanitizeMailHtml(
|
||||
'<img src="/api/mail-images/a"><img src="/api/mail-images/b">'
|
||||
'<img src="/api/mail-images/gone"><img src="/api/mail-images/a">'
|
||||
'<img src="/api/mail-images/b"><img src="/api/mail-images/a">'
|
||||
'<img src="/api/mail-images/b">',
|
||||
imagesShown: true,
|
||||
);
|
||||
const String a = 'data:image/png;base64,AAAAAAAAAAAAAAAAAA'; // 40자
|
||||
const String b = 'data:image/gif;base64,BB'; // 24자
|
||||
// 셋째 a(40)가 넘친다 — 마지막 b(24)는 남은 자리(30)에 들지만 넘는 자리 뒤라 지운다
|
||||
final String resolved = resolveProxiedImages(
|
||||
body.fragment,
|
||||
<String, String>{'a': a, 'b': b},
|
||||
maxTotalLength: 40 + 24 + 40 + 24 + 30,
|
||||
);
|
||||
expect(
|
||||
resolved,
|
||||
'<img src="$a"><img src="$b"><img><img src="$a"><img src="$b"><img><img>',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('정화할 곳 (그 자리 · 다른 isolate)', () {
|
||||
MailBodyBuildPlace placeOf(String? html, [String? text]) =>
|
||||
mailBodyBuildPlaceOf(htmlBody: html, textBody: text);
|
||||
|
||||
test('짧고 얕은 서식 본문 · 짧은 글자 본문만 그 자리에서', () {
|
||||
expect(placeOf('<p>안녕하세요</p>'), MailBodyBuildPlace.inline);
|
||||
expect(placeOf(null, '글자 본문'), MailBodyBuildPlace.inline);
|
||||
expect(placeOf(' ', '공백뿐인 서식 본문은 글자 본문'), MailBodyBuildPlace.inline);
|
||||
expect(
|
||||
placeOf('<p>${'가' * (kMailBodyInlineMaxLength - 8)}</p>'),
|
||||
MailBodyBuildPlace.inline,
|
||||
);
|
||||
});
|
||||
|
||||
test('길면 다른 isolate 에서 — 서식 본문 · 글자 본문 모두', () {
|
||||
expect(
|
||||
placeOf('<p>${'가' * kMailBodyInlineMaxLength}</p>'),
|
||||
MailBodyBuildPlace.isolate,
|
||||
);
|
||||
expect(
|
||||
placeOf(null, '가' * kMailBodyInlineMaxTextLength),
|
||||
MailBodyBuildPlace.isolate,
|
||||
);
|
||||
});
|
||||
|
||||
test('짧아도 깊으면 다른 isolate 에서 — 2,800단 · 30KB 본문, 200단 · 2KB 본문', () {
|
||||
final String deep = '${'<div>' * 2800}끝${'</div>' * 2800}';
|
||||
expect(deep.length, lessThan(32 * 1024));
|
||||
expect(placeOf(deep), MailBodyBuildPlace.isolate);
|
||||
|
||||
final String short = '${'<div>' * 200}끝${'</div>' * 200}';
|
||||
expect(short.length, lessThan(kMailBodyInlineMaxLength));
|
||||
expect(
|
||||
htmlNestingCost(short, limit: 1 << 30),
|
||||
greaterThan(kMailBodyInlineMaxCost),
|
||||
);
|
||||
expect(placeOf(short), MailBodyBuildPlace.isolate);
|
||||
|
||||
final String shallow = '${'<div>' * 40}끝${'</div>' * 40}';
|
||||
expect(placeOf(shallow), MailBodyBuildPlace.inline);
|
||||
});
|
||||
});
|
||||
|
||||
group('본문 조각 · 문서', () {
|
||||
test('HTML 이 없거나 공백뿐이면 글자 본문을 이스케이프해 보인다', () {
|
||||
final SanitizedMailBody text = mailBodyFragment(
|
||||
htmlBody: ' \n ',
|
||||
textBody: '<b>굵게</b>\n둘째 줄',
|
||||
imagesShown: false,
|
||||
);
|
||||
expect(
|
||||
text.fragment,
|
||||
'<div class="mail-text"><b>굵게</b>\n둘째 줄</div>',
|
||||
);
|
||||
expect(displayedHtmlBody(null), isNull);
|
||||
expect(displayedHtmlBody('<p>a</p>'), '<p>a</p>');
|
||||
});
|
||||
|
||||
test('문서는 CSP 를 맨 앞에 두고 바깥 요청 · 스크립트를 막는다', () {
|
||||
final String document = buildMailDocument('<p>본문</p>');
|
||||
expect(
|
||||
kMailDocumentCsp,
|
||||
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'",
|
||||
);
|
||||
expect(
|
||||
document,
|
||||
startsWith(
|
||||
'<!doctype html><html lang="ko"><head><meta charset="utf-8">'
|
||||
'<meta http-equiv="Content-Security-Policy" content="$kMailDocumentCsp">',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
document,
|
||||
contains('<meta name="referrer" content="no-referrer">'),
|
||||
);
|
||||
expect(document, contains('<body><p>본문</p></body>'));
|
||||
expect(document, isNot(contains('<script')));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'package:arasystem_app/features/mail/data/repositories/mail_repository.da
|
||||
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/pages/mail_list_page.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
|
||||
@@ -76,10 +77,17 @@ void main() {
|
||||
MailListPage currentPage(WidgetTester tester) =>
|
||||
tester.widget<MailListPage>(find.byType(MailListPage));
|
||||
|
||||
/// 위에 쌓아 연 전체 화면(읽기 · 쓰기 자리)의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다
|
||||
String pushedLocation(WidgetTester tester) => GoRouterState.of(
|
||||
tester.element(find.byType(PlaceholderPage).last),
|
||||
).uri.toString();
|
||||
/// 위에 쌓아 연 전체 화면(읽기 화면 · 쓰기 자리)의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다
|
||||
String pushedLocation(WidgetTester tester) {
|
||||
final Finder reader = find.byType(MessageReaderPage);
|
||||
return GoRouterState.of(
|
||||
tester.element(
|
||||
reader.evaluate().isNotEmpty
|
||||
? reader.last
|
||||
: find.byType(PlaceholderPage).last,
|
||||
),
|
||||
).uri.toString();
|
||||
}
|
||||
|
||||
group('목록 줄', () {
|
||||
testWidgets('받은편지함 — 보낸 사람 · 시각 · 제목 · 미리 보기, 안 읽음은 굵게 · 첨부 · 중요', (
|
||||
@@ -122,7 +130,7 @@ void main() {
|
||||
expect(tester.getSemantics(avatarOf('채용 일정')).label, '고르기: 채용 일정');
|
||||
});
|
||||
|
||||
testWidgets('줄을 누르면 읽기 화면(탭 막대 없는 전체 화면) — 읽음 처리는 하지 않는다', (
|
||||
testWidgets('줄을 누르면 읽기 화면(탭 막대 없는 전체 화면) — 목록은 읽음 처리를 하지 않는다(읽기 화면이 한다)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.mail.seed(kInboxId, 2);
|
||||
@@ -132,9 +140,16 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(pushedLocation(tester), MailRoutes.message('m1'));
|
||||
expect(find.text(MailTexts.message), findsOneWidget);
|
||||
expect(find.byType(MessageReaderPage), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
// 여러 통 바꾸기는 없다 — 읽음은 읽기 화면의 한 통 바꾸기
|
||||
expect(h.mail.patches, isEmpty);
|
||||
expect(
|
||||
h.mail.singlePatches.map(
|
||||
(({String id, Map<String, Object?> body}) p) => p.id,
|
||||
),
|
||||
<String>['m1'],
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('임시보관함 — 받는 사람을 보이고, 누르면 쓰기 화면에서 이어 쓴다', (
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:arasystem_app/core/files/file_names.dart';
|
||||
import 'package:arasystem_app/core/utils/file_size.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
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/domain/compose_request.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_images.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_links.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/message_reader_view.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/attachment_downloads.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/mail_fakes.dart';
|
||||
|
||||
/// 읽기 화면의 규칙 — 순수 함수 (링크 · mailto · 쓰기 화면 주소 · 파일 이름 · 크기 · 그림 판정 · 안내 · 머리 표기 · 메뉴)
|
||||
void main() {
|
||||
MessageDetail detailOf(
|
||||
Map<String, Object?> overrides, {
|
||||
String id = 'm1',
|
||||
String mailboxId = kInboxId,
|
||||
}) {
|
||||
final MessageDetail? detail = MessageDetail.tryParse(<String, Object?>{
|
||||
...messageJson(id, mailboxId: mailboxId),
|
||||
'cc': <Object?>[],
|
||||
'bcc': <Object?>[],
|
||||
'htmlBody': null,
|
||||
'textBody': '본문',
|
||||
'hasBlockedImages': false,
|
||||
'attachments': <Object?>[],
|
||||
...overrides,
|
||||
});
|
||||
return detail!;
|
||||
}
|
||||
|
||||
group('본문 링크 · 탐색 판정', () {
|
||||
test('처음 넣은 문서(about:blank)만 연다 — 아래 틀은 열지 않는다', () {
|
||||
expect(
|
||||
mailNavigationOf('about:blank', isMainFrame: true),
|
||||
isA<OpenInitialDocument>(),
|
||||
);
|
||||
expect(
|
||||
mailNavigationOf('about:blank', isMainFrame: false),
|
||||
isA<IgnoreNavigation>(),
|
||||
);
|
||||
expect(
|
||||
mailNavigationOf('https://ok.example', isMainFrame: false),
|
||||
isA<IgnoreNavigation>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('http · https 는 바깥 앱으로, mailto 는 쓰기 화면으로, 그 밖은 무시', () {
|
||||
final MailNavigation web = mailLinkOf('https://www.example.com/a?b=1');
|
||||
expect(web, isA<OpenExternally>());
|
||||
expect((web as OpenExternally).uri.host, 'www.example.com');
|
||||
expect(mailLinkOf('HTTP://Example.com'), isA<OpenExternally>());
|
||||
expect(mailLinkOf('mailto:kim@example.com'), isA<OpenCompose>());
|
||||
for (final String url in <String>[
|
||||
'javascript:alert(1)',
|
||||
'file:///data/data/kr.it.arasystem.app/x',
|
||||
'content://kr.it.arasystem.app.downloads/downloads/a',
|
||||
'intent://scan/#Intent;scheme=zxing;end',
|
||||
'tel:01012345678',
|
||||
'about:srcdoc',
|
||||
'/api/messages',
|
||||
'https://',
|
||||
'https://ok.example/\u0000x',
|
||||
'',
|
||||
'https://ok.example/${'a' * kMaxExternalLinkLength}',
|
||||
]) {
|
||||
expect(mailLinkOf(url), isA<IgnoreNavigation>(), reason: url);
|
||||
}
|
||||
});
|
||||
|
||||
test('mailto — 받는 사람 · 참조 · 숨은참조 · 제목 · 본문을 풀고, 틀린 주소 · 겹친 주소는 뺀다', () {
|
||||
final MailNavigation link = mailLinkOf(
|
||||
'mailto:kim@example.com,%20lee@example.com,bad,KIM@example.com'
|
||||
'?cc=park@example.com&bcc=choi@example.com&subject=%ED%9A%8C%EC%9D%98%0A%EC%9D%BC%EC%A0%95'
|
||||
'&body=%EC%95%88%EB%85%95%0A%ED%95%98%EC%84%B8%EC%9A%94&to=yoon@example.com',
|
||||
);
|
||||
final ComposePrefill prefill = (link as OpenCompose).prefill;
|
||||
expect(prefill.to, <String>[
|
||||
'kim@example.com',
|
||||
'lee@example.com',
|
||||
'yoon@example.com',
|
||||
]);
|
||||
expect(prefill.cc, <String>['park@example.com']);
|
||||
expect(prefill.bcc, <String>['choi@example.com']);
|
||||
expect(prefill.subject, '회의 일정');
|
||||
expect(prefill.body, '안녕\n하세요');
|
||||
});
|
||||
|
||||
test('mailto — 제목 · 본문은 한도에서 자른다', () {
|
||||
final ComposePrefill prefill = composePrefillOfMailto(
|
||||
Uri.parse(
|
||||
'mailto:?subject=${'가' * 300}&body=${'나' * (kComposeMaxPrefillBody + 10)}',
|
||||
),
|
||||
)!;
|
||||
expect(prefill.to, isEmpty);
|
||||
expect(prefill.subject.length, kComposeMaxSubject);
|
||||
expect(prefill.body.length, kComposeMaxPrefillBody);
|
||||
expect(composePrefillOfMailto(Uri.parse('https://x.example')), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('쓰기 화면 주소', () {
|
||||
test('답장 · 전체 답장 · 전달 · 이어 쓰기는 웹과 같은 쿼리다', () {
|
||||
expect(MailRoutes.composeReply('m 1'), '/mail/compose?replyTo=m+1');
|
||||
expect(
|
||||
MailRoutes.composeReply('m1', all: true),
|
||||
'/mail/compose?replyTo=m1&all=1',
|
||||
);
|
||||
expect(MailRoutes.composeForward('m1'), '/mail/compose?forward=m1');
|
||||
expect(MailRoutes.composeDraft('d1'), '/mail/compose?draft=d1');
|
||||
});
|
||||
|
||||
test('쿼리 → 여는 까닭 (이어 쓰기 → 답장 → 전달 차례)', () {
|
||||
expect(composeIntentOf(<String, String>{}), isA<NewComposeIntent>());
|
||||
final ComposeIntent reply = composeIntentOf(
|
||||
Uri.parse(MailRoutes.composeReply('m1', all: true)).queryParameters,
|
||||
);
|
||||
expect(reply, isA<ReplyComposeIntent>());
|
||||
expect((reply as ReplyComposeIntent).messageId, 'm1');
|
||||
expect(reply.all, isTrue);
|
||||
expect(
|
||||
composeIntentOf(<String, String>{'replyTo': 'm1'}),
|
||||
isA<ReplyComposeIntent>().having(
|
||||
(ReplyComposeIntent r) => r.all,
|
||||
'all',
|
||||
isFalse,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
composeIntentOf(<String, String>{'forward': 'm2', 'draft': 'd1'}),
|
||||
isA<DraftComposeIntent>(),
|
||||
);
|
||||
expect(
|
||||
composeIntentOf(<String, String>{'forward': 'm2'}),
|
||||
isA<ForwardComposeIntent>(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('받은 파일 이름', () {
|
||||
test('경로 문자는 밑줄로, 제어 · 방향 제어 · 보이지 않는 글자는 지운다', () {
|
||||
expect(safeFileName('../../etc/passwd'), '_.._etc_passwd');
|
||||
expect(safeFileName(r'a\b:c*d?e"f<g>h|i.txt'), 'a_b_c_d_e_f_g_h_i.txt');
|
||||
expect(safeFileName('보고\u0000서\u001F.pdf'), '보고서.pdf');
|
||||
expect(safeFileName('invoice\u202Efdp.exe'), 'invoicefdp.exe');
|
||||
expect(safeFileName('견적.xlsx'), '견적.xlsx');
|
||||
});
|
||||
|
||||
test('앞뒤 공백 · 점을 지우고, 비면 기본 이름', () {
|
||||
expect(safeFileName(' .hidden.txt. '), 'hidden.txt');
|
||||
expect(safeFileName('..'), kFallbackFileName);
|
||||
expect(safeFileName(' \u0007 '), kFallbackFileName);
|
||||
});
|
||||
|
||||
test('UTF-8 200바이트 안으로 줄이고 확장자는 남긴다', () {
|
||||
final String name = safeFileName('${'가' * 120}.docx');
|
||||
expect(utf8.encode(name).length, lessThanOrEqualTo(kMaxFileNameBytes));
|
||||
expect(name, endsWith('.docx'));
|
||||
expect(name, startsWith('가가가'));
|
||||
final String noExtension = safeFileName('a' * 400);
|
||||
expect(noExtension.length, kMaxFileNameBytes);
|
||||
});
|
||||
});
|
||||
|
||||
group('파일 크기 표기 (웹 formatFileSize 와 같다)', () {
|
||||
test('B · KB(정수) · MB(10 미만은 소수 한 자리)', () {
|
||||
expect(formatFileSize(0), '0 B');
|
||||
expect(formatFileSize(1023), '1023 B');
|
||||
expect(formatFileSize(248 * 1024), '248 KB');
|
||||
expect(formatFileSize(1024 * 1024 - 1), '1 MB');
|
||||
expect(formatFileSize((1.2 * 1024 * 1024).round()), '1.2 MB');
|
||||
expect(formatFileSize(3 * 1024 * 1024), '3 MB');
|
||||
expect(formatFileSize(38 * 1024 * 1024), '38 MB');
|
||||
expect(formatFileSize(-1), '');
|
||||
});
|
||||
});
|
||||
|
||||
group('외부 이미지 판정', () {
|
||||
test('앞 바이트로 PNG · JPEG · GIF · WebP 만 그림으로 본다', () {
|
||||
expect(sniffImageType(Uint8List.fromList(kTinyPng)), 'image/png');
|
||||
expect(
|
||||
sniffImageType(Uint8List.fromList(<int>[0xFF, 0xD8, 0xFF, 0xE0])),
|
||||
'image/jpeg',
|
||||
);
|
||||
expect(
|
||||
sniffImageType(Uint8List.fromList(ascii.encode('GIF89a...'))),
|
||||
'image/gif',
|
||||
);
|
||||
expect(
|
||||
sniffImageType(
|
||||
Uint8List.fromList(
|
||||
ascii.encode('RIFF\u0000\u0000\u0000\u0000WEBPVP8 '),
|
||||
),
|
||||
),
|
||||
'image/webp',
|
||||
);
|
||||
expect(
|
||||
sniffImageType(
|
||||
Uint8List.fromList(utf8.encode('<svg onload=alert(1)>')),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(sniffImageType(Uint8List(0)), isNull);
|
||||
});
|
||||
|
||||
test('그림 하나 5MB · 합계 10MB 를 넘으면 넣지 않는다', () {
|
||||
final Uint8List png = Uint8List.fromList(kTinyPng);
|
||||
expect(
|
||||
acceptMailImage(png, usedBytes: 0),
|
||||
startsWith('data:image/png;base64,'),
|
||||
);
|
||||
expect(
|
||||
acceptMailImage(png, usedBytes: kMailImagesTotalMaxBytes - 1),
|
||||
isNull,
|
||||
);
|
||||
final Uint8List big = Uint8List(kMailImageMaxBytes + 1)
|
||||
..setAll(0, kTinyPng);
|
||||
expect(acceptMailImage(big, usedBytes: 0), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('본문 안내 (웹 mailBodyNotices 와 같다)', () {
|
||||
test('생략 → 잘림 차례, 보여 줄 본문이 없으면 잘림은 뺀다', () {
|
||||
expect(
|
||||
mailBodyNoticesOf(
|
||||
textBody: '글',
|
||||
isHtmlBodyOmitted: true,
|
||||
isBodyTruncated: true,
|
||||
),
|
||||
<MailBodyNotice>[MailBodyNotice.htmlOmitted, MailBodyNotice.truncated],
|
||||
);
|
||||
expect(
|
||||
mailBodyNoticesOf(
|
||||
textBody: ' ',
|
||||
isHtmlBodyOmitted: true,
|
||||
isBodyTruncated: true,
|
||||
),
|
||||
<MailBodyNotice>[MailBodyNotice.unavailable],
|
||||
);
|
||||
expect(
|
||||
mailBodyNoticesOf(
|
||||
textBody: null,
|
||||
isHtmlBodyOmitted: false,
|
||||
isBodyTruncated: true,
|
||||
),
|
||||
<MailBodyNotice>[MailBodyNotice.truncated],
|
||||
);
|
||||
expect(
|
||||
mailBodyNoticesOf(
|
||||
textBody: null,
|
||||
isHtmlBodyOmitted: false,
|
||||
isBodyTruncated: false,
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('머리 표기', () {
|
||||
final DateTime now = DateTime(2026, 11, 5, 15);
|
||||
|
||||
test('시각 — 오늘 · 어제 · 올해 · 그 전 (기기 시간대)', () {
|
||||
expect(formatReaderTime(DateTime(2026, 11, 5, 10, 42), now), '오전 10:42');
|
||||
expect(
|
||||
formatReaderTime(DateTime(2026, 11, 4, 16, 20), now),
|
||||
'어제 오후 4:20',
|
||||
);
|
||||
expect(
|
||||
formatReaderTime(DateTime(2026, 3, 1, 9, 5), now),
|
||||
'3월 1일 오전 9:05',
|
||||
);
|
||||
expect(
|
||||
formatReaderTime(DateTime(2025, 10, 24, 12, 30), now),
|
||||
'2025. 10. 24. 오후 12:30',
|
||||
);
|
||||
expect(
|
||||
formatReaderFullDate(DateTime(2026, 11, 4, 16, 20)),
|
||||
'2026년 11월 4일 (수) 오후 4:20',
|
||||
);
|
||||
});
|
||||
|
||||
test('받는 사람 줄 — 지금 직원이면 "나", 받는 사람 · 참조 · 숨은참조를 합쳐 센다', () {
|
||||
final MessageDetail toMe = detailOf(<String, Object?>{
|
||||
'to': <Object?>[addressJson('김아라', 'Ara.Kim@arasystem.it.kr')],
|
||||
});
|
||||
expect(
|
||||
recipientSummaryOf(toMe, myEmail: 'ara.kim@arasystem.it.kr'),
|
||||
'받는 사람 나',
|
||||
);
|
||||
final MessageDetail withCc = detailOf(<String, Object?>{
|
||||
'to': <Object?>[addressJson('김아라', 'ara.kim@arasystem.it.kr')],
|
||||
'cc': <Object?>[
|
||||
addressJson('박준호', 'junho@arasystem.it.kr'),
|
||||
addressJson('김아라', 'ara.kim@arasystem.it.kr'),
|
||||
],
|
||||
});
|
||||
expect(
|
||||
recipientSummaryOf(withCc, myEmail: 'ara.kim@arasystem.it.kr'),
|
||||
'받는 사람 나 외 1명',
|
||||
);
|
||||
final MessageDetail others = detailOf(<String, Object?>{
|
||||
'to': <Object?>[
|
||||
addressJson('박준호', 'junho@arasystem.it.kr'),
|
||||
<String, Object?>{'name': null, 'email': 'x@example.com'},
|
||||
],
|
||||
'bcc': <Object?>[addressJson('최', 'choi@example.com')],
|
||||
});
|
||||
expect(
|
||||
recipientSummaryOf(others, myEmail: 'ara.kim@arasystem.it.kr'),
|
||||
'받는 사람 박준호 외 2명',
|
||||
);
|
||||
expect(
|
||||
recipientSummaryOf(detailOf(const <String, Object?>{}), myEmail: null),
|
||||
MailReaderTexts.noRecipients,
|
||||
);
|
||||
});
|
||||
|
||||
test('펼친 칸의 줄 — 이름이 없거나 주소와 같으면 주소만', () {
|
||||
expect(readerAddressLine(const MailAddress(email: 'a@x.kr', name: '김')), (
|
||||
name: '김',
|
||||
email: 'a@x.kr',
|
||||
));
|
||||
expect(readerAddressLine(const MailAddress(email: 'a@x.kr')), (
|
||||
name: 'a@x.kr',
|
||||
email: null,
|
||||
));
|
||||
expect(
|
||||
readerAddressLine(const MailAddress(email: 'a@x.kr', name: 'a@x.kr')),
|
||||
(name: 'a@x.kr', email: null),
|
||||
);
|
||||
});
|
||||
|
||||
test('붙잡힌 메일의 안내 — 예약은 시각, 보내기 취소를 기다리면 "곧 보낼 메일입니다."', () {
|
||||
expect(
|
||||
pendingNoticeOf(
|
||||
PendingSend(
|
||||
sendAt: DateTime(2026, 11, 7, 9),
|
||||
kind: PendingSendKind.scheduled,
|
||||
),
|
||||
),
|
||||
'11월 7일 (토) 오전 9:00 에 보낼 메일입니다.',
|
||||
);
|
||||
expect(
|
||||
pendingNoticeOf(
|
||||
PendingSend(
|
||||
sendAt: DateTime(2026, 11, 7),
|
||||
kind: PendingSendKind.undo,
|
||||
),
|
||||
),
|
||||
MailReaderTexts.sendingNotice,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('삭제 · 메뉴', () {
|
||||
const Mailbox trash = Mailbox(
|
||||
id: kTrashId,
|
||||
name: '휴지통',
|
||||
role: MailboxRole.trash,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
);
|
||||
|
||||
test('휴지통에만 든 메일만 영구 삭제다 — 다른 메일함에도 있으면 휴지통으로 옮기기', () {
|
||||
MessageSummary inBoxes(List<String> ids) =>
|
||||
detailOf(<String, Object?>{'mailboxIds': ids}).summary;
|
||||
expect(deletesPermanently(inBoxes(<String>[kTrashId]), trash), isTrue);
|
||||
expect(
|
||||
deletesPermanently(inBoxes(<String>[kTrashId, kInboxId]), trash),
|
||||
isFalse,
|
||||
);
|
||||
expect(deletesPermanently(inBoxes(<String>[kInboxId]), trash), isFalse);
|
||||
expect(deletesPermanently(inBoxes(<String>[kTrashId]), null), isFalse);
|
||||
});
|
||||
|
||||
MailboxesState ready() => MailboxesState(
|
||||
status: MailboxesStatus.ready,
|
||||
items: <Mailbox>[
|
||||
for (final Map<String, Object?> json in standardMailboxes())
|
||||
Mailbox.tryParse(json)!,
|
||||
],
|
||||
);
|
||||
|
||||
test('메뉴 — 읽음 · 중요는 늘, 스팸메일함에서는 스팸 아님, 붙잡힌 메일은 이동 · 스팸이 없다', () {
|
||||
final List<String> normal = <String>[
|
||||
for (final ReaderMenuItem item in readerMenuOf(
|
||||
detailOf(<String, Object?>{'isRead': true}),
|
||||
ready(),
|
||||
))
|
||||
item.label,
|
||||
];
|
||||
expect(normal, <String>[
|
||||
MailReaderTexts.markUnread,
|
||||
MailReaderTexts.flag,
|
||||
MailReaderTexts.move,
|
||||
MailReaderTexts.reportSpam,
|
||||
]);
|
||||
final List<String> junk = <String>[
|
||||
for (final ReaderMenuItem item in readerMenuOf(
|
||||
detailOf(<String, Object?>{
|
||||
'isFlagged': true,
|
||||
'mailboxIds': <String>[kJunkId],
|
||||
}),
|
||||
ready(),
|
||||
))
|
||||
item.label,
|
||||
];
|
||||
expect(junk, <String>[
|
||||
MailReaderTexts.markRead,
|
||||
MailReaderTexts.unflag,
|
||||
MailReaderTexts.move,
|
||||
MailReaderTexts.notSpam,
|
||||
]);
|
||||
final List<String> held = <String>[
|
||||
for (final ReaderMenuItem item in readerMenuOf(
|
||||
detailOf(<String, Object?>{
|
||||
'mailboxIds': <String>[kSentId],
|
||||
'pendingSend': pendingJson(DateTime.utc(2026, 11, 7)),
|
||||
}),
|
||||
ready(),
|
||||
))
|
||||
item.label,
|
||||
];
|
||||
expect(held, <String>[MailReaderTexts.markRead, MailReaderTexts.flag]);
|
||||
// 메일함 목록을 받기 전에는 옮길 곳 · 스팸메일함을 몰라 이동 · 스팸이 없다
|
||||
expect(
|
||||
readerMenuOf(
|
||||
detailOf(const <String, Object?>{}),
|
||||
const MailboxesState(),
|
||||
),
|
||||
hasLength(2),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('메일 한 통 읽기 (MessageDetail)', () {
|
||||
test('9단계 뒤 칸이 없으면 거짓 · 빈 목록으로 읽고, 본문 그림은 첨부 목록에서 뺀다', () {
|
||||
final MessageDetail detail = detailOf(<String, Object?>{
|
||||
'attachments': <Object?>[
|
||||
attachmentJson('a1'),
|
||||
attachmentJson(
|
||||
'a2',
|
||||
name: 'sig.png',
|
||||
type: 'image/png',
|
||||
isInline: true,
|
||||
),
|
||||
],
|
||||
'largeFiles': <Object?>[
|
||||
<String, Object?>{'fileId': 'f1', 'name': '큰파일.zip', 'size': 3},
|
||||
<String, Object?>{'fileId': 1},
|
||||
],
|
||||
});
|
||||
expect(detail.imagesShown, isFalse);
|
||||
expect(detail.isBodyTruncated, isFalse);
|
||||
expect(
|
||||
detail.listedAttachments.map((MessageAttachment a) => a.id),
|
||||
<String>['a1'],
|
||||
);
|
||||
expect(detail.largeFiles.single.name, '큰파일.zip');
|
||||
});
|
||||
|
||||
test('모양이 틀리면 null — 첨부 · 본문 칸의 형식', () {
|
||||
expect(detailOfOrNull(<String, Object?>{'htmlBody': 3}), isNull);
|
||||
expect(
|
||||
detailOfOrNull(<String, Object?>{
|
||||
'attachments': <Object?>[
|
||||
<String, Object?>{'id': 'a'},
|
||||
],
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
expect(detailOfOrNull(<String, Object?>{'cc': 'x'}), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('첨부 받기 진행', () {
|
||||
test('비율 — 전체를 모르면 null', () {
|
||||
expect(const AttachmentDownload(received: 64, total: 100).percent, 64);
|
||||
expect(const AttachmentDownload(received: 64, total: 100).fraction, 0.64);
|
||||
expect(const AttachmentDownload(received: 5).percent, isNull);
|
||||
expect(const AttachmentDownload(received: 500, total: 100).percent, 100);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
MessageDetail? detailOfOrNull(Map<String, Object?> overrides) =>
|
||||
MessageDetail.tryParse(<String, Object?>{
|
||||
...messageJson('m1', mailboxId: kInboxId),
|
||||
'textBody': '본문',
|
||||
...overrides,
|
||||
});
|
||||
@@ -1,12 +1,19 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:arasystem_app/core/files/downloaded_files.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
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/mail_repository.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/file_fakes.dart';
|
||||
import '../../support/mail_fakes.dart';
|
||||
|
||||
/// 메일 저장소 — 계약 2절의 경로 · 본문 · 응답 읽기 (인터셉터 줄을 그대로 탄다)
|
||||
@@ -202,4 +209,293 @@ void main() {
|
||||
await errorOf(repository.mailState());
|
||||
expect(h.notifier.shownCodes, <String>['MAIL_001']);
|
||||
});
|
||||
|
||||
group('읽기 화면', () {
|
||||
Map<String, Object?> detailJson({
|
||||
Map<String, Object?> extra = const <String, Object?>{},
|
||||
}) => <String, Object?>{
|
||||
...messageJson('m1', mailboxId: kInboxId),
|
||||
'cc': <Object?>[],
|
||||
'bcc': <Object?>[],
|
||||
'htmlBody': '<p>본문</p>',
|
||||
'textBody': '본문',
|
||||
'hasBlockedImages': true,
|
||||
'imagesShown': false,
|
||||
'attachments': <Object?>[attachmentJson('a1')],
|
||||
...extra,
|
||||
};
|
||||
|
||||
test('메일 한 통 — images=show 는 [이미지 보기]에만 싣고, 처음 받기의 실패는 알리지 않는다', () async {
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.message('m1'),
|
||||
(_) => FakeResponse.ok(detailJson()),
|
||||
);
|
||||
final DateTime serverTime = DateTime.utc(2026, 11, 6, 9);
|
||||
h.server.serverTime = serverTime;
|
||||
|
||||
final ({MessageDetail detail, DateTime? serverTime}) first =
|
||||
await repository.getMessage('m1');
|
||||
await repository.getMessage('m1', showImages: true, silent: false);
|
||||
|
||||
expect(first.detail.hasBlockedImages, isTrue);
|
||||
expect(first.detail.attachments.single.id, 'a1');
|
||||
expect(first.serverTime, serverTime);
|
||||
expect(h.server.requests[0].queryParameters, isEmpty);
|
||||
expect(h.server.requests[1].queryParameters, <String, Object?>{
|
||||
kMailImagesQuery: kMailImagesShow,
|
||||
});
|
||||
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.message('m1'),
|
||||
(_) => FakeResponse.fail(404, 'RES_001'),
|
||||
);
|
||||
expect(
|
||||
apiErrorOf(await errorOf(repository.getMessage('m1'))).code,
|
||||
'RES_001',
|
||||
);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
expect(
|
||||
apiErrorOf(
|
||||
await errorOf(
|
||||
repository.getMessage('m1', showImages: true, silent: false),
|
||||
),
|
||||
).code,
|
||||
'RES_001',
|
||||
);
|
||||
expect(h.notifier.shownCodes, <String>['RES_001']);
|
||||
});
|
||||
|
||||
test(
|
||||
'붙잡은 발송 취소 — DELETE /scheduled-messages/{id} 의 draftId, 이미 보냈으면 MAIL_014 를 알린다',
|
||||
() async {
|
||||
h.server.on(
|
||||
'DELETE',
|
||||
MailApiPaths.scheduledMessage('s/1'),
|
||||
(_) => FakeResponse.ok(<String, Object?>{'draftId': 's/1'}),
|
||||
);
|
||||
expect(
|
||||
MailApiPaths.scheduledMessage('s/1'),
|
||||
'/scheduled-messages/s%2F1',
|
||||
);
|
||||
expect(await repository.cancelPendingSend('s/1'), 's/1');
|
||||
|
||||
h.server.on(
|
||||
'DELETE',
|
||||
MailApiPaths.scheduledMessage('s/1'),
|
||||
(_) => FakeResponse.fail(409, 'MAIL_014'),
|
||||
);
|
||||
expect(
|
||||
apiErrorOf(await errorOf(repository.cancelPendingSend('s/1'))).code,
|
||||
'MAIL_014',
|
||||
);
|
||||
expect(h.notifier.shownCodes, <String>['MAIL_014']);
|
||||
},
|
||||
);
|
||||
|
||||
test('외부 이미지 — 바이트를 받고 실패는 알리지 않는다, 한도를 넘게 오면 끊는다', () async {
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.mailImage('tok'),
|
||||
(_) => FakeResponse.bytes(kTinyPng, contentType: 'image/png'),
|
||||
);
|
||||
expect(await repository.fetchMailImage('tok', maxBytes: 1024), kTinyPng);
|
||||
expect(bearerTokenOf(h.server.requests.last), 'access-1');
|
||||
|
||||
expect(
|
||||
(await errorOf(repository.fetchMailImage('tok', maxBytes: 4))
|
||||
as DioException)
|
||||
.type,
|
||||
DioExceptionType.cancel,
|
||||
);
|
||||
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.mailImage('tok'),
|
||||
(_) => FakeResponse.fail(404, 'RES_001'),
|
||||
);
|
||||
expect(
|
||||
apiErrorOf(
|
||||
await errorOf(repository.fetchMailImage('tok', maxBytes: 1024)),
|
||||
).code,
|
||||
'RES_001',
|
||||
);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
test('첨부 받기 — 조각마다 이어 쓰고 진행을 알린다(길이 머리가 없으면 전체는 null)', () async {
|
||||
final StreamController<Uint8List> chunks = StreamController<Uint8List>();
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.attachment('m1', 'a 1'),
|
||||
(_) => FakeResponse.stream(chunks.stream, contentLength: 6),
|
||||
);
|
||||
final MemoryFileStore files = MemoryFileStore();
|
||||
final DownloadTarget target = await files.create('견적서.pdf');
|
||||
final List<(int, int?)> progress = <(int, int?)>[];
|
||||
|
||||
final Future<void> done = repository.downloadAttachment(
|
||||
messageId: 'm1',
|
||||
attachmentId: 'a 1',
|
||||
target: target,
|
||||
onProgress: (int received, int? total) =>
|
||||
progress.add((received, total)),
|
||||
);
|
||||
await pumpEventsUntil(
|
||||
() => h.server.requests.isNotEmpty,
|
||||
reason: '요청이 서버에 닿기',
|
||||
);
|
||||
chunks.add(Uint8List.fromList(<int>[1, 2, 3]));
|
||||
await pumpEventsUntil(() => progress.isNotEmpty, reason: '첫 조각');
|
||||
chunks.add(Uint8List.fromList(<int>[4, 5, 6]));
|
||||
await chunks.close();
|
||||
await done;
|
||||
await target.close();
|
||||
|
||||
expect(
|
||||
MailApiPaths.attachment('m1', 'a 1'),
|
||||
'/messages/m1/attachments/a%201',
|
||||
);
|
||||
expect(progress, <(int, int?)>[(3, 6), (6, 6)]);
|
||||
expect(files.files[target.path], <int>[1, 2, 3, 4, 5, 6]);
|
||||
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.attachment('m1', 'a 1'),
|
||||
(_) => FakeResponse.stream(Stream<Uint8List>.value(Uint8List(2))),
|
||||
);
|
||||
final List<int?> totals = <int?>[];
|
||||
await repository.downloadAttachment(
|
||||
messageId: 'm1',
|
||||
attachmentId: 'a 1',
|
||||
target: await files.create('b'),
|
||||
onProgress: (int _, int? total) => totals.add(total),
|
||||
);
|
||||
expect(totals, <int?>[null]);
|
||||
});
|
||||
|
||||
test('첨부 받기의 실패 본문(스트림)도 코드로 읽는다 — 서버가 거절하면 인터셉터가 그 코드로 알린다', () async {
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.attachment('m1', 'a1'),
|
||||
(_) => FakeResponse.fail(409, 'FILE_008'),
|
||||
);
|
||||
final MemoryFileStore files = MemoryFileStore();
|
||||
final Object error = await errorOf(
|
||||
repository.downloadAttachment(
|
||||
messageId: 'm1',
|
||||
attachmentId: 'a1',
|
||||
target: await files.create('a'),
|
||||
),
|
||||
);
|
||||
// 상태(409)로만 정하면 SYS_001 이다 — 본문의 코드를 읽었다
|
||||
expect(apiErrorOf(error).code, 'FILE_008');
|
||||
expect(apiErrorOf(error).traceId, 'trace-FILE_008');
|
||||
expect(h.notifier.shownCodes, <String>['FILE_008']);
|
||||
});
|
||||
|
||||
test(
|
||||
'받는 도중 끊기거나 조각 사이 한도를 넘기면 SYS_001(DownloadInterrupted — 인터셉터는 알리지 않았다), 그만두면 취소',
|
||||
() async {
|
||||
final StreamController<Uint8List> broken =
|
||||
StreamController<Uint8List>();
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.attachment('m1', 'a1'),
|
||||
(_) => FakeResponse.stream(broken.stream, contentLength: 10),
|
||||
);
|
||||
final MemoryFileStore files = MemoryFileStore();
|
||||
final Future<Object> failure = errorOf(
|
||||
repository.downloadAttachment(
|
||||
messageId: 'm1',
|
||||
attachmentId: 'a1',
|
||||
target: await files.create('a'),
|
||||
),
|
||||
);
|
||||
await pumpEventsUntil(
|
||||
() => h.server.requests.isNotEmpty,
|
||||
reason: '요청이 서버에 닿기',
|
||||
);
|
||||
broken.add(Uint8List(3));
|
||||
broken.addError(const SocketException('끊김(시험)'));
|
||||
final Object error = await failure;
|
||||
expect(error, isA<DownloadInterrupted>());
|
||||
expect(apiErrorOf(error).code, 'SYS_001');
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
|
||||
// 조각 사이 받기 한도(Dio 가 스트림에 싣는 receiveTimeout)도 끊긴 것이다 — 그만둔 것(cancel)만 그대로 둔다
|
||||
final StreamController<Uint8List> stalled =
|
||||
StreamController<Uint8List>();
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.attachment('m1', 'a1'),
|
||||
(_) => FakeResponse.stream(stalled.stream, contentLength: 10),
|
||||
);
|
||||
final Future<Object> timedOut = errorOf(
|
||||
repository.downloadAttachment(
|
||||
messageId: 'm1',
|
||||
attachmentId: 'a1',
|
||||
target: await files.create('t'),
|
||||
),
|
||||
);
|
||||
await pumpEventsUntil(
|
||||
() => h.server.requests.length == 2,
|
||||
reason: '두 번째 요청이 서버에 닿기',
|
||||
);
|
||||
stalled.add(Uint8List(1));
|
||||
stalled.addError(
|
||||
DioException.receiveTimeout(
|
||||
timeout: const Duration(seconds: 15),
|
||||
requestOptions: RequestOptions(
|
||||
path: MailApiPaths.attachment('m1', 'a1'),
|
||||
),
|
||||
),
|
||||
);
|
||||
final Object timeout = await timedOut;
|
||||
expect(timeout, isA<DownloadInterrupted>());
|
||||
expect(apiErrorOf(timeout).code, 'SYS_001');
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
|
||||
final StreamController<Uint8List> slow = StreamController<Uint8List>();
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.attachment('m1', 'a1'),
|
||||
(_) => FakeResponse.stream(slow.stream, contentLength: 10),
|
||||
);
|
||||
final CancelToken cancel = CancelToken();
|
||||
final Future<Object> cancelled = errorOf(
|
||||
repository.downloadAttachment(
|
||||
messageId: 'm1',
|
||||
attachmentId: 'a1',
|
||||
target: await files.create('b'),
|
||||
cancelToken: cancel,
|
||||
),
|
||||
);
|
||||
await pumpEventsUntil(
|
||||
() => h.server.requests.length == 3,
|
||||
reason: '세 번째 요청이 서버에 닿기',
|
||||
);
|
||||
slow.add(Uint8List(2));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
cancel.cancel();
|
||||
expect(
|
||||
((await cancelled) as DioException).type,
|
||||
DioExceptionType.cancel,
|
||||
);
|
||||
await slow.close();
|
||||
},
|
||||
);
|
||||
|
||||
test('바이트 응답의 실패 본문도 코드로 읽는다(그림 · 파일)', () async {
|
||||
h.server.on('GET', '/files/x', (_) => FakeResponse.fail(410, 'FILE_007'));
|
||||
final Object error = await errorOf(
|
||||
h.dio.get<List<int>>(
|
||||
'/files/x',
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
),
|
||||
);
|
||||
expect(apiErrorOf(error).code, 'FILE_007');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
|
||||
|
||||
/// 가짜 WebView — 시험 기기(Windows · 머리 없는 시험)에는 WebView 가 없다.
|
||||
///
|
||||
/// 읽기 화면이 실제로 하는 일(스크립트 끄기 · 탐색 판정 걸기 · 문서 넣기)을 적어 두고, 시험이 링크를 누른 것처럼 탐색을 물을 수 있다.
|
||||
/// 안드로이드 전용 설정(`AndroidWebViewController` — 파일 · content 접근 끄기 등)은 이 가짜에서는 지나간다 — 실기기에서 본다.
|
||||
class FakeWebViewPlatform extends WebViewPlatform {
|
||||
/// 만든 컨트롤러 — 차례대로
|
||||
final List<FakeWebViewController> controllers = <FakeWebViewController>[];
|
||||
|
||||
/// 마지막으로 만든 컨트롤러
|
||||
FakeWebViewController get last => controllers.last;
|
||||
|
||||
@override
|
||||
PlatformWebViewController createPlatformWebViewController(
|
||||
PlatformWebViewControllerCreationParams params,
|
||||
) {
|
||||
final FakeWebViewController controller = FakeWebViewController(params);
|
||||
controllers.add(controller);
|
||||
return controller;
|
||||
}
|
||||
|
||||
@override
|
||||
PlatformNavigationDelegate createPlatformNavigationDelegate(
|
||||
PlatformNavigationDelegateCreationParams params,
|
||||
) => FakeNavigationDelegate(params);
|
||||
|
||||
@override
|
||||
PlatformWebViewWidget createPlatformWebViewWidget(
|
||||
PlatformWebViewWidgetCreationParams params,
|
||||
) => FakeWebViewWidget(params);
|
||||
}
|
||||
|
||||
/// 가짜 WebView 컨트롤러 — 받은 설정과 넣은 문서를 적는다
|
||||
class FakeWebViewController extends PlatformWebViewController {
|
||||
FakeWebViewController(super.params) : super.implementation();
|
||||
|
||||
JavaScriptMode? javaScriptMode;
|
||||
Color? backgroundColor;
|
||||
FakeNavigationDelegate? navigationDelegate;
|
||||
|
||||
/// 넣은 문서 — 차례대로
|
||||
final List<String> loadedHtml = <String>[];
|
||||
|
||||
/// 넣은 문서의 기준 주소 — 차례대로(읽기 화면은 늘 null 이어야 한다)
|
||||
final List<String?> baseUrls = <String?>[];
|
||||
|
||||
String? get lastHtml => loadedHtml.isEmpty ? null : loadedHtml.last;
|
||||
|
||||
@override
|
||||
Future<void> setJavaScriptMode(JavaScriptMode javaScriptMode) async {
|
||||
this.javaScriptMode = javaScriptMode;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setBackgroundColor(Color color) async {
|
||||
backgroundColor = color;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setPlatformNavigationDelegate(
|
||||
PlatformNavigationDelegate handler,
|
||||
) async {
|
||||
navigationDelegate = handler as FakeNavigationDelegate;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadHtmlString(String html, {String? baseUrl}) async {
|
||||
loadedHtml.add(html);
|
||||
baseUrls.add(baseUrl);
|
||||
}
|
||||
|
||||
/// 링크를 누른 것처럼 탐색을 묻는다 — 판정(열기 · 막기)을 돌려준다
|
||||
Future<NavigationDecision> navigate(
|
||||
String url, {
|
||||
bool isMainFrame = true,
|
||||
}) async {
|
||||
final NavigationRequestCallback? callback =
|
||||
navigationDelegate?.onNavigationRequest;
|
||||
if (callback == null) return NavigationDecision.navigate;
|
||||
return callback(NavigationRequest(url: url, isMainFrame: isMainFrame));
|
||||
}
|
||||
}
|
||||
|
||||
/// 가짜 탐색 판정 — 건 처리기를 쥔다
|
||||
class FakeNavigationDelegate extends PlatformNavigationDelegate {
|
||||
FakeNavigationDelegate(super.params) : super.implementation();
|
||||
|
||||
NavigationRequestCallback? onNavigationRequest;
|
||||
|
||||
@override
|
||||
Future<void> setOnNavigationRequest(
|
||||
NavigationRequestCallback onNavigationRequest,
|
||||
) async {
|
||||
this.onNavigationRequest = onNavigationRequest;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setOnPageStarted(PageEventCallback onPageStarted) async {}
|
||||
|
||||
@override
|
||||
Future<void> setOnPageFinished(PageEventCallback onPageFinished) async {}
|
||||
|
||||
@override
|
||||
Future<void> setOnProgress(ProgressCallback onProgress) async {}
|
||||
|
||||
@override
|
||||
Future<void> setOnWebResourceError(
|
||||
WebResourceErrorCallback onWebResourceError,
|
||||
) async {}
|
||||
|
||||
@override
|
||||
Future<void> setOnUrlChange(UrlChangeCallback onUrlChange) async {}
|
||||
|
||||
@override
|
||||
Future<void> setOnHttpAuthRequest(
|
||||
HttpAuthRequestCallback onHttpAuthRequest,
|
||||
) async {}
|
||||
|
||||
@override
|
||||
Future<void> setOnHttpError(HttpResponseErrorCallback onHttpError) async {}
|
||||
}
|
||||
|
||||
/// 가짜 WebView 자리 — 아무것도 그리지 않는다
|
||||
class FakeWebViewWidget extends PlatformWebViewWidget {
|
||||
FakeWebViewWidget(super.params) : super.implementation();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
const SizedBox.expand(key: ValueKey<String>('fake-webview'));
|
||||
}
|
||||
@@ -141,7 +141,45 @@ class RecordingNotifier extends ErrorNotifier {
|
||||
|
||||
/// 가짜 응답
|
||||
class FakeResponse {
|
||||
const FakeResponse(this.status, this.body, {this.rawBody});
|
||||
const FakeResponse(
|
||||
this.status,
|
||||
this.body, {
|
||||
this.rawBody,
|
||||
this.bytes,
|
||||
this.stream,
|
||||
this.headers,
|
||||
});
|
||||
|
||||
/// 표준 래퍼 없는 바이트(그림 · 파일) — [contentType] 과 길이 머리를 싣는다
|
||||
factory FakeResponse.bytes(
|
||||
List<int> data, {
|
||||
String contentType = 'application/octet-stream',
|
||||
int status = 200,
|
||||
}) => FakeResponse(
|
||||
status,
|
||||
null,
|
||||
bytes: data,
|
||||
headers: <String, List<String>>{
|
||||
Headers.contentTypeHeader: <String>[contentType],
|
||||
Headers.contentLengthHeader: <String>['${data.length}'],
|
||||
},
|
||||
);
|
||||
|
||||
/// 조각으로 오는 파일 — 시험이 조각을 하나씩 흘린다. [contentLength] 가 없으면 길이 머리를 싣지 않는다
|
||||
factory FakeResponse.stream(
|
||||
Stream<Uint8List> chunks, {
|
||||
int? contentLength,
|
||||
String contentType = 'application/octet-stream',
|
||||
}) => FakeResponse(
|
||||
200,
|
||||
null,
|
||||
stream: chunks,
|
||||
headers: <String, List<String>>{
|
||||
Headers.contentTypeHeader: <String>[contentType],
|
||||
if (contentLength != null)
|
||||
Headers.contentLengthHeader: <String>['$contentLength'],
|
||||
},
|
||||
);
|
||||
|
||||
/// 표준 성공 `{ success: true, data }`
|
||||
factory FakeResponse.ok([Object? data]) =>
|
||||
@@ -166,6 +204,15 @@ class FakeResponse {
|
||||
|
||||
/// JSON 이 아닌 그대로의 본문 (있으면 [body] 대신)
|
||||
final String? rawBody;
|
||||
|
||||
/// 바이트 본문 (있으면 [body] · [rawBody] 대신)
|
||||
final List<int>? bytes;
|
||||
|
||||
/// 조각으로 오는 본문 (있으면 다른 본문 대신)
|
||||
final Stream<Uint8List>? stream;
|
||||
|
||||
/// 더 싣는 머리 — 같은 이름은 기본(JSON)을 덮는다
|
||||
final Map<String, List<String>>? headers;
|
||||
}
|
||||
|
||||
typedef FakeHandler = FutureOr<FakeResponse> Function(RequestOptions options);
|
||||
@@ -180,10 +227,32 @@ class FakeServer implements HttpClientAdapter {
|
||||
/// 응답의 `Date` 머리(서버 시각) — null 이면 싣지 않는다
|
||||
DateTime? serverTime;
|
||||
|
||||
/// 경로 꼴마다 처리기 — 같은 경로의 [on] 이 없을 때 먼저 맞는 것
|
||||
final List<({String method, RegExp path, FakeHandler handler})> _patterns =
|
||||
<({String method, RegExp path, FakeHandler handler})>[];
|
||||
|
||||
void on(String method, String path, FakeHandler handler) {
|
||||
_routes['$method $path'] = handler;
|
||||
}
|
||||
|
||||
/// 경로 꼴(`^/messages/([^/]+)$` 등)에 처리기를 건다 — 처리기는 [RequestOptions.path] 로 칸을 꺼낸다
|
||||
void onPattern(String method, RegExp path, FakeHandler handler) {
|
||||
_patterns.add((method: method, path: path, handler: handler));
|
||||
}
|
||||
|
||||
FakeHandler? _handlerOf(RequestOptions options) {
|
||||
final FakeHandler? exact = _routes['${options.method} ${options.path}'];
|
||||
if (exact != null) return exact;
|
||||
for (final ({String method, RegExp path, FakeHandler handler}) pattern
|
||||
in _patterns) {
|
||||
if (pattern.method == options.method &&
|
||||
pattern.path.hasMatch(options.path)) {
|
||||
return pattern.handler;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// [method] [path] 로 받은 요청들
|
||||
List<RequestOptions> received(String method, String path) => requests
|
||||
.where((RequestOptions r) => r.method == method && r.path == path)
|
||||
@@ -197,7 +266,7 @@ class FakeServer implements HttpClientAdapter {
|
||||
) async {
|
||||
// 받은 그때의 모습을 남긴다 — 인터셉터가 실패를 내보내며 토큰 · 비밀 본문을 지워도 시험이 볼 수 있게
|
||||
requests.add(options.copyWith());
|
||||
final FakeHandler? handler = _routes['${options.method} ${options.path}'];
|
||||
final FakeHandler? handler = _handlerOf(options);
|
||||
final FakeResponse response = handler == null
|
||||
? FakeResponse.fail(404, 'RES_001')
|
||||
: await handler(options);
|
||||
@@ -208,14 +277,23 @@ class FakeServer implements HttpClientAdapter {
|
||||
);
|
||||
}
|
||||
final DateTime? date = serverTime;
|
||||
final Map<String, List<String>> headers = <String, List<String>>{
|
||||
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
|
||||
if (date != null) HttpHeaders.dateHeader: <String>[HttpDate.format(date)],
|
||||
...?response.headers,
|
||||
};
|
||||
final Stream<Uint8List>? stream = response.stream;
|
||||
if (stream != null) {
|
||||
return ResponseBody(stream, response.status, headers: headers);
|
||||
}
|
||||
final List<int>? bytes = response.bytes;
|
||||
if (bytes != null) {
|
||||
return ResponseBody.fromBytes(bytes, response.status, headers: headers);
|
||||
}
|
||||
return ResponseBody.fromString(
|
||||
response.rawBody ?? jsonEncode(response.body),
|
||||
response.status,
|
||||
headers: <String, List<String>>{
|
||||
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
|
||||
if (date != null)
|
||||
HttpHeaders.dateHeader: <String>[HttpDate.format(date)],
|
||||
},
|
||||
headers: headers,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:arasystem_app/core/files/downloaded_files.dart';
|
||||
import 'package:arasystem_app/core/files/file_names.dart';
|
||||
|
||||
/// 메모리 파일 보관 — 시험에서는 기기 파일 시스템을 쓰지 않는다(시험 시계 안에서 파일 입출력이 끝나지 않는다)
|
||||
class MemoryFileStore implements DownloadedFileStore {
|
||||
/// 다 받은 파일 — 경로마다 바이트
|
||||
final Map<String, List<int>> files = <String, List<int>>{};
|
||||
|
||||
/// 받다 말아 지운 자리
|
||||
final List<String> discarded = <String>[];
|
||||
|
||||
/// 받는 중인 자리
|
||||
final Map<String, List<int>> writing = <String, List<int>>{};
|
||||
int purgeCount = 0;
|
||||
int _created = 0;
|
||||
|
||||
/// 켜면 자리 만들기가 실패한다(저장 공간 없음)
|
||||
bool failCreate = false;
|
||||
|
||||
/// 있으면 받다 만 자리 지우기가 이것이 끝날 때까지 기다린다 — 앞 받기의 끝을 늦춘다
|
||||
Completer<void>? discardGate;
|
||||
|
||||
@override
|
||||
Future<DownloadTarget> create(String fileName) async {
|
||||
if (failCreate) throw const FileSystemException('저장 공간 없음(시험)');
|
||||
final String path =
|
||||
'/cache/downloads/${++_created}/${safeFileName(fileName)}';
|
||||
writing[path] = <int>[];
|
||||
return _MemoryTarget(this, path);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> exists(String path) async => files.containsKey(path);
|
||||
|
||||
@override
|
||||
Future<void> purge() async {
|
||||
purgeCount++;
|
||||
files.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class _MemoryTarget implements DownloadTarget {
|
||||
_MemoryTarget(this._store, this.path);
|
||||
|
||||
final MemoryFileStore _store;
|
||||
|
||||
@override
|
||||
final String path;
|
||||
|
||||
@override
|
||||
void add(List<int> chunk) => _store.writing[path]?.addAll(chunk);
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
final List<int>? bytes = _store.writing.remove(path);
|
||||
if (bytes != null) _store.files[path] = bytes;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> discard() async {
|
||||
await _store.discardGate?.future;
|
||||
_store.writing.remove(path);
|
||||
_store.files.remove(path);
|
||||
_store.discarded.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// 다른 앱으로 연 것을 적는 가짜 — [result] 로 결과를 정한다
|
||||
class RecordingFileOpener implements FileOpener {
|
||||
FileOpenResult result = FileOpenResult.opened;
|
||||
final List<({String path, String mimeType})> opened =
|
||||
<({String path, String mimeType})>[];
|
||||
|
||||
@override
|
||||
Future<FileOpenResult> open({
|
||||
required String path,
|
||||
required String mimeType,
|
||||
}) async {
|
||||
opened.add((path: path, mimeType: mimeType));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// 바깥 앱으로 연 링크를 적는 가짜 — [result] 로 열었는지 정한다
|
||||
class RecordingLinkLauncher {
|
||||
bool result = true;
|
||||
final List<Uri> launched = <Uri>[];
|
||||
|
||||
Future<bool> call(Uri uri) async {
|
||||
launched.add(uri);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,27 @@ Map<String, Object?> addressJson(String name, String email) =>
|
||||
Map<String, Object?> pendingJson(DateTime sendAt, {String kind = 'undo'}) =>
|
||||
<String, Object?>{'sendAt': sendAt.toUtc().toIso8601String(), 'kind': kind};
|
||||
|
||||
/// 메일의 첨부 하나 (계약 2절 `MessageAttachment`)
|
||||
Map<String, Object?> attachmentJson(
|
||||
String id, {
|
||||
String name = '견적서.pdf',
|
||||
String type = 'application/pdf',
|
||||
int size = 248 * 1024,
|
||||
bool isInline = false,
|
||||
}) => <String, Object?>{
|
||||
'id': id,
|
||||
'name': name,
|
||||
'type': type,
|
||||
'size': size,
|
||||
'isInline': isInline,
|
||||
};
|
||||
|
||||
/// 1×1 PNG (외부 이미지 중계의 그림)
|
||||
final List<int> kTinyPng = <int>[
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, //
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||
];
|
||||
|
||||
/// 기본 메일함 다섯과 내 메일함 셋(프로젝트 아래에 하나)
|
||||
List<Map<String, Object?>> standardMailboxes({
|
||||
int inboxUnread = 3,
|
||||
@@ -131,6 +152,48 @@ class FakeMailBackend {
|
||||
/// 받은 POST /message-deletions 의 ids
|
||||
final List<List<String>> deletions = <List<String>>[];
|
||||
|
||||
// ── 읽기 화면 (메일 한 통 · 붙잡힌 발송 · 첨부 · 외부 이미지) ──
|
||||
|
||||
/// 메일 한 통의 읽기 칸 — 메일 id 마다(없으면 텍스트 본문 하나). `messageJson` 위에 덮는다
|
||||
final Map<String, Map<String, Object?>> details =
|
||||
<String, Map<String, Object?>>{};
|
||||
|
||||
/// `images=show` 로 물었을 때 덮는 칸 — 메일 id 마다(외부 이미지를 중계 주소로 바꾼 본문)
|
||||
final Map<String, Map<String, Object?>> shownImageDetails =
|
||||
<String, Map<String, Object?>>{};
|
||||
|
||||
/// 받은 GET /messages/{id} 의 쿼리 (메일 id · images)
|
||||
final List<({String id, Object? images})> detailRequests =
|
||||
<({String id, Object? images})>[];
|
||||
|
||||
/// 받은 PATCH /messages/{id} (메일 id · 본문)
|
||||
final List<({String id, Map<String, Object?> body})> singlePatches =
|
||||
<({String id, Map<String, Object?> body})>[];
|
||||
|
||||
/// 받은 DELETE /scheduled-messages/{id} 의 메일 id
|
||||
final List<String> cancellations = <String>[];
|
||||
|
||||
FakeResponse? detailFailure;
|
||||
FakeResponse? singlePatchFailure;
|
||||
FakeResponse? cancelFailure;
|
||||
|
||||
/// 있으면 읽기 칸 응답이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다)
|
||||
Completer<void>? detailGate;
|
||||
|
||||
/// 있으면 한 통 바꾸기 응답이 이것이 끝날 때까지 기다린다
|
||||
Completer<void>? singlePatchGate;
|
||||
|
||||
/// 첨부 받기의 응답 — 첨부 id 마다(없으면 404)
|
||||
final Map<String, FakeResponse Function()> attachmentFiles =
|
||||
<String, FakeResponse Function()>{};
|
||||
|
||||
/// 외부 이미지 중계의 응답 — 표마다(없으면 404)
|
||||
final Map<String, FakeResponse> mailImages = <String, FakeResponse>{};
|
||||
|
||||
List<RequestOptions> get imageRequests => server.requests
|
||||
.where((RequestOptions r) => r.path.startsWith('/mail-images/'))
|
||||
.toList();
|
||||
|
||||
int get stateRequests =>
|
||||
server.received('GET', MailApiPaths.mailState).length;
|
||||
|
||||
@@ -238,8 +301,126 @@ class FakeMailBackend {
|
||||
if (failure != null) return failure;
|
||||
return FakeResponse.ok(<String, Object?>{'state': state});
|
||||
});
|
||||
_installReader();
|
||||
}
|
||||
|
||||
static final RegExp _messagePath = RegExp(r'^/messages/([^/]+)$');
|
||||
static final RegExp _attachmentPath = RegExp(
|
||||
r'^/messages/([^/]+)/attachments/([^/]+)$',
|
||||
);
|
||||
static final RegExp _scheduledPath = RegExp(r'^/scheduled-messages/([^/]+)$');
|
||||
static final RegExp _imagePath = RegExp(r'^/mail-images/([^/]+)$');
|
||||
|
||||
String _idOf(RegExp pattern, String path, [int group = 1]) =>
|
||||
Uri.decodeComponent(pattern.firstMatch(path)!.group(group)!);
|
||||
|
||||
void _installReader() {
|
||||
server.onPattern('GET', _messagePath, (RequestOptions options) async {
|
||||
final String id = _idOf(_messagePath, options.path);
|
||||
final Object? images = options.queryParameters[kMailImagesQuery];
|
||||
detailRequests.add((id: id, images: images));
|
||||
final Completer<void>? gate = detailGate;
|
||||
if (gate != null) {
|
||||
detailGate = null;
|
||||
await gate.future;
|
||||
}
|
||||
final FakeResponse? failure = detailFailure;
|
||||
if (failure != null) return failure;
|
||||
final Map<String, Object?>? detail = detailJsonOf(
|
||||
id,
|
||||
showImages: images == kMailImagesShow,
|
||||
);
|
||||
return detail == null
|
||||
? FakeResponse.fail(404, 'RES_001')
|
||||
: FakeResponse.ok(detail);
|
||||
});
|
||||
server.onPattern('PATCH', _messagePath, (RequestOptions options) async {
|
||||
final String id = _idOf(_messagePath, options.path);
|
||||
final Map<String, Object?> body = options.data! as Map<String, Object?>;
|
||||
singlePatches.add((id: id, body: body));
|
||||
await singlePatchGate?.future;
|
||||
final FakeResponse? failure = singlePatchFailure;
|
||||
if (failure != null) return failure;
|
||||
final ({String mailboxId, Map<String, Object?> message})? found = _find(
|
||||
id,
|
||||
);
|
||||
if (found == null) return FakeResponse.fail(404, 'RES_001');
|
||||
final bool moves =
|
||||
body.containsKey('mailboxId') || body.containsKey('isJunk');
|
||||
if (moves && found.message['pendingSend'] != null) {
|
||||
return FakeResponse.fail(409, 'MAIL_015');
|
||||
}
|
||||
_apply(id, body);
|
||||
return FakeResponse.ok(Map<String, Object?>.of(_find(id)!.message));
|
||||
});
|
||||
server.onPattern('DELETE', _scheduledPath, (RequestOptions options) async {
|
||||
final String id = _idOf(_scheduledPath, options.path);
|
||||
cancellations.add(id);
|
||||
final FakeResponse? failure = cancelFailure;
|
||||
if (failure != null) return failure;
|
||||
final ({String mailboxId, Map<String, Object?> message})? found = _find(
|
||||
id,
|
||||
);
|
||||
if (found == null) return FakeResponse.fail(404, 'RES_001');
|
||||
if (found.message['pendingSend'] == null) {
|
||||
return FakeResponse.fail(409, 'MAIL_014');
|
||||
}
|
||||
found.message['pendingSend'] = null;
|
||||
_apply(id, <String, Object?>{'mailboxId': kDraftsId});
|
||||
return FakeResponse.ok(<String, Object?>{'draftId': id});
|
||||
});
|
||||
server.onPattern('GET', _attachmentPath, (RequestOptions options) {
|
||||
final String attachmentId = _idOf(_attachmentPath, options.path, 2);
|
||||
final FakeResponse Function()? file = attachmentFiles[attachmentId];
|
||||
return file == null ? FakeResponse.fail(404, 'RES_001') : file();
|
||||
});
|
||||
server.onPattern('GET', _imagePath, (RequestOptions options) {
|
||||
final String token = _idOf(_imagePath, options.path);
|
||||
return mailImages[token] ?? FakeResponse.fail(404, 'RES_001');
|
||||
});
|
||||
}
|
||||
|
||||
/// 메일 한 통의 읽기 칸 — 목록 칸 위에 [details] (와 외부 이미지를 보일 때 [shownImageDetails])를 덮는다. 없는 메일은 null
|
||||
Map<String, Object?>? detailJsonOf(String id, {bool showImages = false}) {
|
||||
final ({String mailboxId, Map<String, Object?> message})? found = _find(id);
|
||||
if (found == null) return null;
|
||||
return <String, Object?>{
|
||||
...found.message,
|
||||
'cc': <Object?>[],
|
||||
'bcc': <Object?>[],
|
||||
'replyTo': <Object?>[],
|
||||
'sentAt': found.message['receivedAt'],
|
||||
'messageId': '<$id@example.com>',
|
||||
'references': <Object?>[],
|
||||
'htmlBody': null,
|
||||
'textBody': '본문 $id',
|
||||
'hasBlockedImages': false,
|
||||
'imagesShown': false,
|
||||
'isHtmlBodyOmitted': false,
|
||||
'isBodyTruncated': false,
|
||||
'attachments': <Object?>[],
|
||||
'largeFiles': <Object?>[],
|
||||
...?details[id],
|
||||
if (showImages) ...?shownImageDetails[id],
|
||||
};
|
||||
}
|
||||
|
||||
/// 메일 하나를 [mailboxId] 에 두고 읽기 칸을 [detail] 로 덮는다
|
||||
void put(
|
||||
String mailboxId,
|
||||
Map<String, Object?> message, {
|
||||
Map<String, Object?> detail = const <String, Object?>{},
|
||||
}) {
|
||||
(messages[mailboxId] ??= <Map<String, Object?>>[]).add(message);
|
||||
details[message['id']! as String] = detail;
|
||||
}
|
||||
|
||||
/// 메일이 지금 든 메일함 (없으면 null)
|
||||
String? mailboxOf(String id) => _find(id)?.mailboxId;
|
||||
|
||||
/// 메일의 지금 칸 (없으면 null)
|
||||
Map<String, Object?>? messageOf(String id) => _find(id)?.message;
|
||||
|
||||
/// [mailboxId] 에 메일 [count] 통을 둔다 — id 는 `<prefix>1` 부터(앞이 최신)
|
||||
void seed(
|
||||
String mailboxId,
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
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/router/app_router.dart';
|
||||
import 'package:arasystem_app/core/utils/provider_retry.dart';
|
||||
import 'package:arasystem_app/features/mail/data/mail_link_launcher.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
|
||||
|
||||
import 'fake_webview.dart';
|
||||
import 'fakes.dart';
|
||||
import 'file_fakes.dart';
|
||||
import 'mail_fakes.dart';
|
||||
import 'provider_harness.dart';
|
||||
|
||||
@@ -19,12 +24,22 @@ const String kMailTestAccount = 'ara.kim@arasystem.it.kr';
|
||||
/// 휴대폰 화면 크기 (보드의 390 × 844)
|
||||
const Size kPhoneSize = Size(390, 844);
|
||||
|
||||
/// 메일 화면 시험대 — 로그인한 앱 전체를 가짜 서버(인증 + 메일)로 띄운다
|
||||
/// 메일 화면 시험대 — 로그인한 앱 전체를 가짜 서버(인증 + 메일)로 띄운다.
|
||||
///
|
||||
/// 기기에 기대는 것은 가짜로 바꾼다: WebView([webView]), 받은 파일 보관([files]) · 다른 앱으로 열기([opener]),
|
||||
/// 바깥 브라우저([links]).
|
||||
class MailAppHarness {
|
||||
MailAppHarness();
|
||||
|
||||
final ProviderHarness base = ProviderHarness();
|
||||
late final FakeMailBackend mail = FakeMailBackend(base.server);
|
||||
final FakeWebViewPlatform webView = FakeWebViewPlatform();
|
||||
final MemoryFileStore files = MemoryFileStore();
|
||||
final RecordingFileOpener opener = RecordingFileOpener();
|
||||
final RecordingLinkLauncher links = RecordingLinkLauncher();
|
||||
|
||||
/// 시험마다 더하는 덮어쓰기 — [pump] 전에 채운다
|
||||
final List<Override> overrides = <Override>[];
|
||||
|
||||
FakeServer get server => base.server;
|
||||
ManualClock get clock => base.clock;
|
||||
@@ -47,6 +62,7 @@ class MailAppHarness {
|
||||
tester.view.physicalSize = size * 3;
|
||||
tester.view.devicePixelRatio = 3;
|
||||
addTearDown(tester.view.reset);
|
||||
WebViewPlatform.instance = webView;
|
||||
await base.prepare(refreshToken: 'refresh-1');
|
||||
server.on(
|
||||
'POST',
|
||||
@@ -61,7 +77,13 @@ class MailAppHarness {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
retry: noAutomaticRetry,
|
||||
overrides: <Override>[...base.overrides],
|
||||
overrides: <Override>[
|
||||
...base.overrides,
|
||||
downloadedFileStoreProvider.overrideWithValue(files),
|
||||
fileOpenerProvider.overrideWithValue(opener),
|
||||
mailLinkLauncherProvider.overrideWithValue(links.call),
|
||||
...overrides,
|
||||
],
|
||||
child: const App(),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user