merge: W18 — App 内预览对非文本拒收(跟上 v0.61 的 .pdf)
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user