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

This commit is contained in:
AskaEth committed 2026-10-04 21:17:21 +08:00
commit 1f9d1dde01
4 files changed
+229 -17

No files matched your search

@@ -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 {
@@ -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() },
@@ -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)
}
}
@@ -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