From 48e41886baade156e2014955faf61cde6f87256f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 21:16:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(documents):=20App=20=E5=86=85=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E5=AF=B9=E9=9D=9E=E6=96=87=E6=9C=AC=E6=8B=92=E6=94=B6?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E8=B7=9F=E4=B8=8A=20v0.61=20=E7=9A=84?= =?UTF-8?q?=20.pdf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 v0.61 把文档白名单 22 → 23 项(新增 .pdf)并改成(扩展名, 字节嗅探)成对裁决, 读侧 MIME 如实下发 application/pdf。而 W11 的附件预览是把字节当 UTF-8 文本渲染的 (那时白名单里除 .pdf 外全是文本类)⇒ 一旦点开别的端发来的 PDF 附件,预览就是一整屏 二进制乱码,像文件坏了。这次堵的是这个兼容洞,不是新功能。 - 选文件器加 application/pdf:原先刻意不列(那时必被后端 415 拒),现在 PDF 在选择器里 不该是灰的,服务端收得下。 - 新增 DocumentPreviewGate(纯函数):mime 非空时只看服务端那次字节嗅探(以 text/ 开头 判为文本,其余一律非文本,未知 mime 也判非文本——两侧代价不对称,误判成文本正是本闸 要消灭的乱码);mime 缺失/空白时按扩展名兜底(白名单里只有 .pdf 是非文本),这条兜底 服务的是待发附件(它只有本地 URI 与显示名)。大小写不敏感并剥掉 ; charset 参数。 - 弹层新增 plan 参数:判为非文本时正文区留空、只留一句人话并指路两条出口——弹层照常打开, 文件名/体积/字数/状态与两条出口一个不少,也绝不假装加载失败(字节其实是取回来的, 只是本端不渲染它)。 - 两条出口刻意未动:打开用 DTO 的 mime(PDF 就该交给 PDF 阅读器,不用通配符),保存继续 传 */*(W11 实测结论:SAF 会按 mime 给文件名补扩展名)。 - 测试 766 → 775(DocumentPreviewTest 新增 9 条:闸的判定矩阵、扩展名兜底与大小写、 未知 mime 拒收、待发附件接线、以及 PDF 的打开类型不是通配符)。 未做(缺口留给主代理登记 02):DTO 只有 status=unreadable,没有服务端那句原因文案 (含「图片型/无文本层 PDF,需要 OCR」)⇒ 客户端沿用既有通用文案「读不到」, 不自己拼一句假装是服务端说的。 --- .../cyrene/domain/chat/DocumentPreview.kt | 96 ++++++++++++++++++ .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 19 +++- .../cyrene/ui/chat/DocumentPreviewDialog.kt | 33 +++++-- .../cyrene/domain/chat/DocumentPreviewTest.kt | 98 ++++++++++++++++++- 4 files changed, 229 insertions(+), 17 deletions(-) diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentPreview.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentPreview.kt index 60d4125..ab0853e 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentPreview.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentPreview.kt @@ -53,6 +53,19 @@ data class DocumentTarget( is DocumentSource.Local -> DocumentOpenTypes.forLocal(declaredLocalType) } + /** + * App 内**正文区**该怎么渲染([DocumentPreviewGate] 的裁决,纯函数)。 + * + * 与 [openType] 一起把「哪个字段才是真源」的知识留在这一个类里:服务端来源用 DTO 的 + * `mime`,本地待发附件**没有**服务端 `mime`(那时还没上传过),只能按显示名兜底。 + * 本地那条**刻意不问** `ContentResolver.getType`——平台给 `.rs` 这类文件常报 + * `application/octet-stream` 或空,拿它当判据会把一批纯文本文件挡在预览之外。 + */ + fun previewPlan(): DocumentPreviewPlan = when (val origin = source) { + is DocumentSource.Server -> DocumentPreviewGate.plan(origin.mime, origin.name) + is DocumentSource.Local -> DocumentPreviewGate.plan("", origin.displayName) + } + /** * 保存到下载目录时建议的文件名。 * @@ -134,6 +147,89 @@ object DocumentOpen { "没有应用能打开「$type」格式的文件,可以「保存到下载目录」后再处理" } +/** + * App 内**正文区**能不能按文本渲染。 + * + * 弹层的三条出口与「正文区」是两件事:判成 [NotText] 时弹层**照常打开**(文件名 · 体积 · + * 字数 · 状态与两条出口都在),只是**正文区留空** + 一句人话。 + */ +sealed interface DocumentPreviewPlan { + + /** 文本类:正文按 [DocumentPreview.slice] 的结果渲染。 */ + data object Text : DocumentPreviewPlan + + /** + * 非文本类(PDF 这类二进制内容):**绝不把字节当 UTF-8 渲染**(那会是一整屏乱码), + * 也**不假装加载失败**(这不是失败,字节取回来了,只是本端不预览它)。 + */ + data class NotText(val message: String) : DocumentPreviewPlan +} + +/** + * 「App 内文本预览」这道闸(纯函数,可单测)。 + * + * ## 为什么要有这道闸 + * W11 的预览是把字节当 UTF-8 文本渲染的——那时白名单里的附件**除了 `.pdf` 之外全是文本类** + * (`.pdf` 正是 `v0.61` 才新增的第 23 项),所以「取回字节 → 解码 → 显示」在那些附件上是成立的。 + * 纳入 `.pdf` 之后,同一段代码会把一份 PDF 渲染成一整屏二进制乱码:看着像文件坏了, + * 实际是本端拿错了渲染方式。 + * + * ## 判据(优先服务端,退而扩展名) + * 1. `mime` 非空 ⇒ **只看服务端那一次字节嗅探**(契约 §1.2:嗅探是唯一裁决权): + * 以 `text/` 开头的判为文本,其余(`application/pdf` / `application/octet-stream` / 图片型 …) + * 一律判为**非文本**; + * 2. `mime` 缺失 / 空白 ⇒ 按**扩展名**兜底:白名单 23 项里除 `.pdf` 外都是文本类,所以只有 + * `.pdf` 判为非文本(这条兜底是给**待发附件**用的——它只有本地 URI 与显示名,没有服务端结论); + * 3. 未知 `mime`(既不是 `text/` 那一族、也不是认识的类型)⇒ **判为非文本**。两侧的代价不对称: + * 误判成非文本只是少一次预览(两条出口照旧完整可用),误判成文本却正是这道闸要消灭的乱码。 + * + * 大小写不敏感,且**剥掉参数**(`text/plain; charset=utf-8` 的基类型是 `text/plain`)—— + * 带参数的 Content-Type 是常态,不该因为多了一截 `; charset=…` 就整份附件不给预览。 + */ +object DocumentPreviewGate { + + /** `.pdf` 的类型标签:写进那句人话里(用户认得的是「PDF」而不是 `application/pdf`)。 */ + const val PDF_LABEL: String = "PDF" + + /** + * @param mime 服务端 DTO 里的 `mime`(嗅探所得基类型);待发附件传空串。 + * @param name 服务端存储名或待发附件的显示名——**只在 [mime] 为空时才看它**。 + */ + fun plan(mime: String, name: String): DocumentPreviewPlan { + val base = baseType(mime) + if (base.isEmpty()) return extensionPlan(name) + return if (base.startsWith(TEXT_PREFIX)) DocumentPreviewPlan.Text else refuse(base) + } + + /** 非文本那句人话。**说清「不是失败」**(两下都在弹层里摆着)并指路能拿到完整内容的两条出口。 */ + fun refusalMessage(label: String): String = + "这是 $label,App 内不预览这类内容,请用「用其它应用打开」或「保存到下载目录」" + + /** 去掉参数、去空白、转小写:`"text/plain; charset=utf-8"` → `"text/plain"`。 */ + fun baseType(mime: String): String = + mime.substringBefore(';').trim().lowercase(Locale.ROOT) + + /** 扩展名(小写、无点);没有扩展名时回空串。 */ + private fun extensionOf(name: String): String = + name.substringAfterLast('.', "").trim().lowercase(Locale.ROOT) + + private fun extensionPlan(name: String): DocumentPreviewPlan = + if (extensionOf(name) == PDF_EXTENSION) refuse(PDF_LABEL) else DocumentPreviewPlan.Text + + private fun refuse(baseType: String): DocumentPreviewPlan { + // 已知是 PDF 就报「PDF」(用户认得这个名字),未知类型**原样报 mime**——绝不替服务端 + // 编一个更好听的类型名。 + val label = if (baseType == PDF_MIME) PDF_LABEL else baseType + return DocumentPreviewPlan.NotText(refusalMessage(label)) + } + + private const val TEXT_PREFIX = "text/" + + private const val PDF_MIME = "application/pdf" + + private const val PDF_EXTENSION = "pdf" +} + /** 预览的截断阈值(两个上限,**先到者为准**)。 */ object DocumentPreviewLimits { diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index ecf36bf..f13c0bd 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -105,15 +105,20 @@ private val InputBottomSpacer = 22.dp /** * 选文件时交给系统选择器的 MIME 列表。 * - * 判据不是「后端白名单的 22 个扩展名」本身——那 22 个扩展名里有 `.go` / `.rs` / `.sql` / `.log`, + * 判据不是「后端白名单的 23 个扩展名」本身——那份名单里有 `.go` / `.rs` / `.sql` / `.log`, * 各家文件管理器给它们报的类型五花八门(`text/x-go`、`application/octet-stream`……)。列窄了 * 会出现「后端收得下、用户却选不到」的假失败,所以这里取**语义上贴近文本/代码**的那一族, * 并含 `application/octet-stream`(未知扩展名的常见报法)。 * - * **刻意不列**已经确定会被后端拒的那几类(`application/pdf`、`application/zip`、 - * `image/svg+xml`、`text/html` 等):让它们在选择器里就是灰的,比让用户传完再看一句 - * 「服务端不接受这个格式」更省事。最终裁决仍是**服务端的字节嗅探**——这里放行的,服务端 - * 照旧可以用 `415 unsupported_media_type` 拒掉,那条文案会原样显示给用户,不假装成功。 + * `application/pdf` 是 `v0.61` 新增的服务端白名单第 23 项(文本层 PDF 可抽取进上下文)—— + * **列进来**才是对的:不列的话用户手里那份 PDF 在选择器里就是灰的,而服务端明明收得下。 + * 它落进 App 内预览时不会渲染正文([top.yeij.cyrene.domain.chat.DocumentPreviewGate] 判为非文本), + * 但那属于**弹层怎么显示**,与**能不能选**是两件事。 + * + * **刻意不列**确定会被后端拒的那几类(`application/zip`、`image/svg+xml`、`text/html` 等): + * 让它们在选择器里就是灰的,比让用户传完再看一句「服务端不接受这个格式」更省事。最终裁决 + * 仍是**服务端的字节嗅探 ∧ 扩展名**那一对成对判定——这里放行的,服务端照旧可以用 + * `415 unsupported_media_type` 拒掉,那条文案会原样显示给用户,不假装成功。 */ private val DocumentPickerMimeTypes = arrayOf( "text/*", @@ -124,6 +129,7 @@ private val DocumentPickerMimeTypes = arrayOf( "text/yaml", "application/toml", "application/x-toml", + "application/pdf", "application/octet-stream", ) @@ -521,6 +527,9 @@ fun ChatScreen( DocumentPreviewDialog( target = target, state = previewState, + // 正文区能不能按文本渲染:服务端来源看 DTO 的 mime,待发附件按显示名兜底—— + // 判定在 domain 里,接线层不自己猜(`application/pdf` 因此不会渲染成一屏乱码)。 + plan = target.previewPlan(), notice = previewNotice, onRetry = { previewReload++ }, onOpen = { openPreviewDocument() }, diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/DocumentPreviewDialog.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/DocumentPreviewDialog.kt index d3bd1ee..78ddfc0 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/DocumentPreviewDialog.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/DocumentPreviewDialog.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import top.yeij.cyrene.data.repository.CachedDocument import top.yeij.cyrene.domain.chat.DocumentPreview +import top.yeij.cyrene.domain.chat.DocumentPreviewPlan import top.yeij.cyrene.domain.chat.DocumentTarget import top.yeij.cyrene.domain.chat.PreviewSlice import top.yeij.cyrene.ui.common.PreferenceCard @@ -63,6 +64,12 @@ data class DocumentPreviewNotice(val message: String, val error: Boolean = false * 后两条在字节还没到位([DocumentPreviewState.Loading] / [DocumentPreviewState.Failed])时 * **不建可点**:点了也只能失败,不如让它们明摆着还没准备好。 * + * ## 正文区由 [plan] 说了算 + * 非文本([DocumentPreviewPlan.NotText],如 `application/pdf` 的字节)**不渲染正文**——本端 + * 只会把字节当 UTF-8 文本显示,一份 PDF 那样子看起来就是一屏乱码,像文件坏了。此时正文区 + * 只留 [DocumentPreviewPlan.NotText.message] 那句人话:**这既不是加载失败**(字节是取回来的, + * 两条出口照旧可用),也不是「文件是空的」。 + * * ## 正文为什么是 `LazyColumn` + 分块 * 上限是 5000 行 / 256 KB,整段塞进一个 `Text` 会让打开弹层的那一刻卡在排版上。切成 * [top.yeij.cyrene.domain.chat.DocumentPreviewLimits.LINES_PER_CHUNK] 行一块交给 `LazyColumn`, @@ -74,6 +81,7 @@ data class DocumentPreviewNotice(val message: String, val error: Boolean = false * 截断**:用户看到一段像是完整的正文却其实是前 256 KB,是最容易被误读的一种失败。 * * @param target 被点开的那一枚芯片(来源判定见 [DocumentTarget.of],弹层不自己判断)。 + * @param plan 正文区能不能按文本渲染([DocumentTarget.previewPlan] 的产物,弹层不自己判断)。 * @param notice 「用其它应用打开 / 保存」这一步的如实告知(没有 App 能开、保存成功或失败); * null = 没什么要说的。 */ @@ -81,6 +89,7 @@ data class DocumentPreviewNotice(val message: String, val error: Boolean = false fun DocumentPreviewDialog( target: DocumentTarget, state: DocumentPreviewState, + plan: DocumentPreviewPlan, notice: DocumentPreviewNotice?, onRetry: () -> Unit, onOpen: () -> Unit, @@ -121,16 +130,22 @@ fun DocumentPreviewDialog( TextButton("重试", onClick = onRetry) } - is DocumentPreviewState.Ready -> { - state.slice.notice?.let { text -> - Text( - text = text, - style = MiuixTheme.textStyles.footnote2, - color = MiuixTheme.colorScheme.onSurfaceVariantSummary, - modifier = Modifier.padding(bottom = 8.dp), - ) + is DocumentPreviewState.Ready -> when (plan) { + DocumentPreviewPlan.Text -> { + state.slice.notice?.let { text -> + Text( + text = text, + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + PreviewBody(state.slice) } - PreviewBody(state.slice) + + // 非文本:**正文区留空**,只留那句人话。字节其实已经取回来了(两条出口要用), + // 只是本端不会渲染它——把 PDF 的字节按 UTF-8 显示出来就是一屏乱码。 + is DocumentPreviewPlan.NotText -> PreviewHint(plan.message) } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentPreviewTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentPreviewTest.kt index b227df6..a6a90e0 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentPreviewTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentPreviewTest.kt @@ -13,13 +13,15 @@ import top.yeij.cyrene.data.repository.LocalDocumentTooLargeException import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException /** - * 附件预览的纯逻辑:截断规则、来源选择、打开用的类型、以及「没有 App 能开」那条分支。 + * 附件预览的纯逻辑:截断规则、来源选择、打开用的类型、正文闸、以及「没有 App 能开」那条分支。 * - * 这里锚的四件事都是**用户能看见的后果**,不是内部实现: + * 这里锚的五件事都是**用户能看见的后果**,不是内部实现: * 1. 截断必须**明写**(阈值边界逐条钉住:正好等于上限 / 超一字节 / 超一行); * 2. 「用其它应用打开」的类型**只用服务端嗅探所得的 `mime`**,绝不按扩展名猜; * 3. 没有任何 App 能开时**如实告知**并指路保存,**不静默失败**; - * 4. 来源判定只有一处([DocumentTarget.of]),服务端来源**只能用存储名**当文件名。 + * 4. 来源判定只有一处([DocumentTarget.of]),服务端来源**只能用存储名**当文件名; + * 5. App 内**正文**只预览文本类(v0.61 的 `.pdf` 因此不会被当 UTF-8 渲染成一屏乱码), + * 且非文本那句人话**不许读起来像失败**。 */ class DocumentPreviewTest { @@ -201,6 +203,96 @@ class DocumentPreviewTest { assertEquals("text/csv", DocumentOpenTypes.forLocal("text/csv")) } + // —— App 内文本预览这道闸(v0.61 的 .pdf)—— + + @Test + fun `a text mime renders the body`() { + assertEquals(DocumentPreviewPlan.Text, DocumentPreviewGate.plan("text/plain", "a.md")) + assertEquals(DocumentPreviewPlan.Text, DocumentPreviewGate.plan("text/csv", "a.csv")) + } + + @Test + fun `a mime with parameters and odd casing still counts as text`() { + // 带参数的 Content-Type 是常态——不该因为多了一截 `; charset=…` 就整份附件不给预览。 + assertEquals(DocumentPreviewPlan.Text, DocumentPreviewGate.plan("text/plain; charset=utf-8", "a.md")) + assertEquals(DocumentPreviewPlan.Text, DocumentPreviewGate.plan("TEXT/Plain;Charset=UTF-8", "a.md")) + } + + @Test + fun `a pdf mime refuses the body and points at the two exits`() { + val plan = DocumentPreviewGate.plan("application/pdf", "0123456789abcdef0123456789abcdef.pdf") + + assertTrue(plan is DocumentPreviewPlan.NotText) + val message = (plan as DocumentPreviewPlan.NotText).message + assertTrue(message, message.contains("PDF")) + assertTrue(message, message.contains("用其它应用打开")) + assertTrue(message, message.contains("保存到下载目录")) + } + + @Test + fun `a missing mime falls back to the extension where only pdf is not text`() { + // 白名单 23 项里除 .pdf 外全是文本类,所以这条兜底只挡 .pdf。它服务的是**待发附件** + // (只有本地 URI 与显示名,没有服务端嗅探结论)。 + assertEquals(DocumentPreviewPlan.Text, DocumentPreviewGate.plan("", "0123456789abcdef.md")) + assertEquals(DocumentPreviewPlan.Text, DocumentPreviewGate.plan(" ", "笔记.csv")) + assertTrue(DocumentPreviewGate.plan("", "报告.pdf") is DocumentPreviewPlan.NotText) + } + + @Test + fun `the extension fallback ignores casing`() { + assertTrue(DocumentPreviewGate.plan("", "REPORT.PDF") is DocumentPreviewPlan.NotText) + assertEquals(DocumentPreviewPlan.Text, DocumentPreviewGate.plan("", "REPORT.MD")) + } + + @Test + fun `an unknown mime refuses rather than risking a screen of garbage`() { + // 两侧代价不对称:误判成非文本只是少一次预览(两条出口照旧),误判成文本正是本闸要 + // 消灭的乱码。 + val plan = DocumentPreviewGate.plan("application/octet-stream", "a.bin") + + assertTrue(plan is DocumentPreviewPlan.NotText) + // 未知类型**原样报 mime**,不替服务端编一个更好听的类型名。 + assertTrue((plan as DocumentPreviewPlan.NotText).message.contains("application/octet-stream")) + assertTrue(DocumentPreviewGate.plan("image/png", "a.png") is DocumentPreviewPlan.NotText) + } + + @Test + fun `the gate reads the same field as the open type`() { + assertTrue(DocumentTarget.of(ref(mime = "application/pdf")).previewPlan() is DocumentPreviewPlan.NotText) + assertEquals(DocumentPreviewPlan.Text, DocumentTarget.of(ref(mime = "text/plain")).previewPlan()) + // 待发附件没有服务端 mime,只能按显示名兜底。 + assertTrue( + DocumentTarget.of(DraftDocument(uri = "content://doc/1", displayName = "报告.pdf")) + .previewPlan() is DocumentPreviewPlan.NotText, + ) + assertEquals( + DocumentPreviewPlan.Text, + DocumentTarget.of(DraftDocument(uri = "content://doc/1", displayName = "报告.md")).previewPlan(), + ) + } + + @Test + fun `a pdf is handed off as a pdf - never as a wildcard`() { + // 「用其它应用打开」要的正是「能读懂这段内容的类型」,PDF 就该交给 PDF 阅读器; + // `*/*` 是**保存**那条路的取值(W11 实测:SAF 会按 mime 给文件名补扩展名),别互相套用。 + val target = DocumentTarget.of(ref(name = "0123456789abcdef0123456789abcdef.pdf", mime = "application/pdf")) + + assertEquals("application/pdf", target.openType()) + assertEquals("application/pdf", DocumentOpenTypes.forServer("application/pdf")) + } + + @Test + fun `no refusal ever reads like a failure or an empty file`() { + val messages = listOf( + DocumentPreviewGate.plan("application/pdf", "a.pdf"), + DocumentPreviewGate.plan("application/octet-stream", "a.bin"), + DocumentPreviewGate.plan("", "a.pdf"), + ).map { (it as DocumentPreviewPlan.NotText).message } + + assertTrue(messages.toString(), messages.all { it.isNotBlank() }) + assertTrue(messages.toString(), messages.none { it.contains("失败") || it.contains("空文件") || it.contains("是空的") }) + } + // —— 没有 App 能开 —— @Test