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:
@@ -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>
|
||||
Reference in New Issue
Block a user