feat(documents): App 内预览对非文本拒收 —— 跟上 v0.61 的 .pdf

后端 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」)⇒ 客户端沿用既有通用文案「读不到」,
不自己拼一句假装是服务端说的。
This commit is contained in:
AskaEth committed 2026-10-04 21:16:29 +08:00
1 parent 020687a82b
commit 48e41886ba
4 files changed
+229 -17

No files matched your search

@@ -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