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:
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
|
||||
|
||||
Reference in new issue
Block a user