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:
2026-11-06 22:26:09 +09:00
parent ef23afc94c
commit a37045a553
51 changed files with 8325 additions and 48 deletions
@@ -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>