feat(chat): 附件芯片可点开预览,并给「用其它应用打开 / 保存到下载目录」两条出口
点一枚附件芯片此前完全没有反应。现在点它会开一个 App 内预览弹层,弹层里再给两条出口。
- FileProvider(authority = ${applicationId}.fileprovider,res/xml/file_paths.xml 只暴露
cacheDir/documents/):把 file:// 交给别的 App 在 API 24+ 会抛 FileUriExposedException,
必须走 content://;因此 cacheDir 下真正要交出去的那一层才被开出来。
- 新增 data/repository/DocumentCache.kt:缓存路径推导 / 命中判定 / 原子落盘是可单测的纯逻辑;
DocumentCacheStore 把「服务端按存储名下载」与「本地待发附件」两条来源汇成同一份文件 + 字节。
服务端存储名是内容寻址(sha256[:32]+ext)且读侧响应 immutable,所以命中即永不失效、命中时
一个请求都不发;先写 .part 再改名、失败删干净——半截文件被下一次「命中」当成完整内容,
是最坏的一种静默错误。
- 新增 domain/chat/DocumentPreview.kt(纯逻辑):截断(首 256 KB 或 5000 行,先到者为准;
字节切点不切坏多字节 UTF-8)、正文分块(200 行一块)、来源判定(服务端 vs 本地,只有一处)、
打开用的 MIME(只用 DTO 里字节嗅探所得的 mime,绝不按扩展名猜)、「没有 App 能开」的裁决与
文案、取字节失败的分类文案。
- 新增 ui/chat/DocumentPreviewDialog.kt:Compose Dialog + Miuix 内容。正文用 LazyColumn 分块渲染
(5000 行不塞进单个 Text),每块各自 SelectionContainer;加载态与失败态分明(失败给重试,
绝不渲染成空文件);截断明写「只显示前 N,完整内容请用…」,不静默截断;复用 DocumentChips
显示类型/体积/字数/状态,不另写一套标签;status 为 unreadable 不挡预览(那是模型读不到,
文件本体一直在)。
- ui/chat/MessageBubble.kt:芯片整枚可点(点击区是那枚芯片本身,不是里面的两行文字),
并带 contentDescription「查看附件 <name>」。
- ui/chat/ChatScreen.kt:预览状态 + 取字节 + SAF 保存 + ACTION_VIEW 打开。没有 App 能开时
如实告知并指路保存,绝不静默失败。
契约缺口(需登记 02):服务端按内容寻址落盘,DTO 里没有原始文件名,保存到下载目录只能用
<32hex>.<ext>——弹层里已把这一点写在界面上,没有替用户编一个假名字。
测试 567 → 610(+29 DocumentPreviewTest / +14 DocumentCacheTest),0 失败 0 错误。
This commit is contained in:
1 parent
e59ef08cf9
commit
27b6910009
9 files changed
+1625
-10
No files matched your search
@@ -0,0 +1,367 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
|
||||
import top.yeij.cyrene.data.repository.LocalDocumentTooLargeException
|
||||
import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 预览 / 打开 / 保存要处理的那一份附件**从哪来**。
|
||||
*
|
||||
* 判定只发生在一处([DocumentTarget.of]),预览取字节、交给别的 App、保存到下载目录三条路
|
||||
* 都从这里取来源——散成两处就会出现「预览读的是服务端、打开读的是本地」这种半截行为。
|
||||
*
|
||||
* 两种来源是**真的事实差异**,不是实现细节:已落库的附件只有服务端存储名(消息 DTO 刻意不带
|
||||
* 正文,见契约 v0.59 §1.2 ④),还没发出去的待发附件**只有**本地 URI(那时还没有存储名)。
|
||||
*/
|
||||
sealed interface DocumentSource {
|
||||
|
||||
/**
|
||||
* 已落库消息里的附件:字节要按服务端存储名现拉(`GET /api/v1/documents/{name}`,要 Bearer)。
|
||||
*
|
||||
* [mime] 是 DTO 里那个**字节嗅探**所得的基类型——「用其它应用打开」就用它,
|
||||
* 不从 [name] 的扩展名猜(见 [DocumentOpenTypes.forServer])。
|
||||
*/
|
||||
data class Server(val name: String, val mime: String) : DocumentSource
|
||||
|
||||
/** 待发附件:字节在本地 URI 上(SAF 的 `content://` 或分享落地后的 `file://`)。 */
|
||||
data class Local(val uri: String, val displayName: String) : DocumentSource
|
||||
}
|
||||
|
||||
/**
|
||||
* 被点开的那一枚附件芯片(纯数据,UI 照着它渲染预览弹层)。
|
||||
*
|
||||
* [chips] 就是气泡里那枚芯片的模型([DocumentChips]),**刻意复用**:弹层再写一套
|
||||
* 「类型 · 体积 · 字数 · 状态」的标签,两边迟早会有一处漏改状态语义。
|
||||
*/
|
||||
data class DocumentTarget(
|
||||
val source: DocumentSource,
|
||||
val chips: DocumentChipModel,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 「用其它应用打开」要声明的类型。
|
||||
*
|
||||
* 服务端来源用 DTO 里嗅探所得的 [DocumentSource.Server.mime];本地来源只能来自平台声明
|
||||
* (`ContentResolver.getType`,由调用方传进来)。**两条路都不从扩展名猜**:服务端的
|
||||
* `data.json` 嗅探回的是 `text/plain`,按扩展名猜成 `application/json` 等于替服务端
|
||||
* 说一个它刻意不承认的类型。
|
||||
*/
|
||||
fun openType(declaredLocalType: String? = null): String = when (val origin = source) {
|
||||
is DocumentSource.Server -> DocumentOpenTypes.forServer(origin.mime)
|
||||
is DocumentSource.Local -> DocumentOpenTypes.forLocal(declaredLocalType)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存到下载目录时建议的文件名。
|
||||
*
|
||||
* ⚠️ **服务端来源只能用存储名**:落盘名是 `sha256(bytes)[:32] + ext`(内容寻址),
|
||||
* 原始文件名**永不参与路径**、也不回客户端——DTO 里根本没有它。所以这里如实给
|
||||
* `<32hex>.<ext>`,**绝不**替用户编一个「用户当初叫它什么」的假名字(那会是本端凭空的
|
||||
* 信息)。本地待发附件倒是还有用户当初选的那份显示名。
|
||||
*/
|
||||
fun suggestedFileName(): String = when (val origin = source) {
|
||||
is DocumentSource.Server -> origin.name
|
||||
is DocumentSource.Local -> origin.displayName.ifBlank { FALLBACK_FILE_NAME }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/** 已落库消息的附件引用 → 目标(来源恒为服务端)。 */
|
||||
fun of(ref: DocumentRefDto): DocumentTarget =
|
||||
DocumentTarget(DocumentSource.Server(ref.name, ref.mime), DocumentChips.of(ref))
|
||||
|
||||
/** 待发附件 → 目标(来源恒为本地)。 */
|
||||
fun of(draft: DraftDocument): DocumentTarget =
|
||||
DocumentTarget(DocumentSource.Local(draft.uri, draft.displayName), DocumentChips.of(draft))
|
||||
}
|
||||
}
|
||||
|
||||
private const val FALLBACK_FILE_NAME = "document.txt"
|
||||
|
||||
/**
|
||||
* 「用其它应用打开」时 Intent 上要声明的 MIME。
|
||||
*
|
||||
* 与 `ChatScreen` 选文件时那份 [DocumentPickerMimeTypes] 相反:那是「让用户选得到」的宽口径,
|
||||
* 这里是「交给别的 App 时声明它是什么」——**只有服务端嗅探(或平台声明)这一个真源**,
|
||||
* 判不出来宁可用 `application/octet-stream`,也不按扩展名猜。
|
||||
*/
|
||||
object DocumentOpenTypes {
|
||||
|
||||
const val OCTET_STREAM: String = "application/octet-stream"
|
||||
|
||||
/** 服务端文档:DTO 的 `mime` 是嗅探所得基类型(唯一裁决);服务端嗅不出类型时也下发 `octet-stream`。 */
|
||||
fun forServer(mime: String): String = mime.trim().ifBlank { OCTET_STREAM }
|
||||
|
||||
/** 本地待发文件:类型只可能来自平台声明;拿不到就回 `octet-stream`(不猜扩展名)。 */
|
||||
fun forLocal(declared: String?): String = declared?.trim()?.takeIf { it.isNotEmpty() } ?: OCTET_STREAM
|
||||
}
|
||||
|
||||
/** 「用其它应用打开」这一步的裁决结果。 */
|
||||
sealed interface DocumentOpenPlan {
|
||||
|
||||
/** 系统里有 App 能处理这个类型:把 Intent 交出去。 */
|
||||
data object HandOff : DocumentOpenPlan
|
||||
|
||||
/**
|
||||
* 没有任何 App 能打开这个类型:**必须如实告知**,并指路「保存到下载目录」。
|
||||
*
|
||||
* 静默什么也不发生是最坏的结果——用户按了按钮,界面纹丝不动,他既不知道失败在哪,
|
||||
* 也不知道还有别的出路。这条分支因此带一句现成的话,而不是一个光秃秃的布尔。
|
||||
*/
|
||||
data class NoHandler(val message: String) : DocumentOpenPlan
|
||||
}
|
||||
|
||||
/** 「用其它应用打开」的裁决(纯函数:`PackageManager` 只回答 [plan] 的 `hasHandler` 那一问)。 */
|
||||
object DocumentOpen {
|
||||
|
||||
/**
|
||||
* @param type 交给 Intent 的 MIME([DocumentOpenTypes] 的产物)。
|
||||
* @param hasHandler 系统里是否有 App 能处理 `ACTION_VIEW + type`(UI 层查 `PackageManager`)。
|
||||
*/
|
||||
fun plan(type: String, hasHandler: Boolean): DocumentOpenPlan =
|
||||
if (hasHandler) DocumentOpenPlan.HandOff else DocumentOpenPlan.NoHandler(noHandlerMessage(type))
|
||||
|
||||
/**
|
||||
* 没有任何 App 能处理 [type] 时的那句话。
|
||||
*
|
||||
* 单独暴露一个入口是因为「解析得到 handler」与「真的启动起来」之间还有一段窗口(App 被
|
||||
* 卸载 / 被停用)——那条 `ActivityNotFoundException` 分支要说的是**同一句话**,不能在那里
|
||||
* 另写一份文案(两份迟早会分叉)。
|
||||
*/
|
||||
fun noHandlerMessage(type: String): String =
|
||||
"没有应用能打开「$type」格式的文件,可以「保存到下载目录」后再处理"
|
||||
}
|
||||
|
||||
/** 预览的截断阈值(两个上限,**先到者为准**)。 */
|
||||
object DocumentPreviewLimits {
|
||||
|
||||
/**
|
||||
* 单次预览最多取前 256 KB 字节。
|
||||
*
|
||||
* 取值理由:附件上限是 20 MiB(`CHAT_DOCUMENT_MAX_BYTES`),一份 20 MiB 的文本全塞进单个
|
||||
* `Text` 会让测量 / 布局在打开弹层那一刻卡住。256 KB 足够看清开头、翻不到底的都在下一条
|
||||
* 出口(「用其它应用打开」/「保存」)——那两条给的是**完整**内容。
|
||||
*/
|
||||
const val MAX_BYTES: Int = 256 * 1024
|
||||
|
||||
/** 行数上限:与字节上限先到者为准(19 万行的短行文件在 256 KB 内也能有 10 万行)。 */
|
||||
const val MAX_LINES: Int = 5_000
|
||||
|
||||
/**
|
||||
* 正文分块的粒度(行)。
|
||||
*
|
||||
* [MAX_LINES] 5000 行如果是一个 `Text`,一次测量就要算整段排版;切成 200 行一块交给
|
||||
* `LazyColumn`,只测当前可见的几块(最多 25 块)。
|
||||
*/
|
||||
const val LINES_PER_CHUNK: Int = 200
|
||||
}
|
||||
|
||||
/** 预览为什么只显示了一段([None] = 完整显示)。 */
|
||||
enum class PreviewTruncation {
|
||||
|
||||
None,
|
||||
|
||||
/** 撞上 [DocumentPreviewLimits.MAX_BYTES]。 */
|
||||
Bytes,
|
||||
|
||||
/** 撞上 [DocumentPreviewLimits.MAX_LINES]。 */
|
||||
Lines,
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览正文的一段切片。
|
||||
*
|
||||
* [shownBytes] / [shownLines] 是**实际显示了多少**,[totalBytes] / [totalLines] 是全文多少。
|
||||
* 截断说明([notice])里报的是**阈值本身**(256 KB / 5,000 行):实际显示量最多比阈值少 3 字节
|
||||
* (UTF-8 回退那一刀),把它精确写进一句话只会得到「只显示前 262,143 字节」这种没人想看的东西。
|
||||
*/
|
||||
data class PreviewSlice(
|
||||
val text: String,
|
||||
val truncatedBy: PreviewTruncation,
|
||||
val shownBytes: Int,
|
||||
val shownLines: Int,
|
||||
val totalBytes: Int,
|
||||
val totalLines: Int,
|
||||
) {
|
||||
|
||||
val truncated: Boolean get() = truncatedBy != PreviewTruncation.None
|
||||
|
||||
/**
|
||||
* 截断时那句**必须明写**的话;完整显示时回 `null`(一个字都不说)。
|
||||
*
|
||||
* 静默截断是这一块的红线:用户看到一段像是「文件就这么长」的正文,会得出错误结论,
|
||||
* 而真相(后面还有)**只有服务端知道**——所以这里既说「只显示前 N」,也指路两个能给
|
||||
* 完整内容的出口。
|
||||
*/
|
||||
val notice: String?
|
||||
get() {
|
||||
val shown = when (truncatedBy) {
|
||||
PreviewTruncation.None -> return null
|
||||
PreviewTruncation.Bytes -> "${DocumentPreviewLimits.MAX_BYTES / 1024} KB"
|
||||
PreviewTruncation.Lines -> "${grouped(DocumentPreviewLimits.MAX_LINES)} 行"
|
||||
}
|
||||
return "只显示前 $shown,完整内容请用「用其它应用打开」或「保存到下载目录」查看"
|
||||
}
|
||||
}
|
||||
|
||||
/** 文档正文的取段与分块(纯函数,UI 与测试共用)。 */
|
||||
object DocumentPreview {
|
||||
|
||||
/**
|
||||
* 取前 [DocumentPreviewLimits.MAX_BYTES] 字节 / [DocumentPreviewLimits.MAX_LINES] 行,
|
||||
* 先到者为准。
|
||||
*
|
||||
* 顺序是「先字节、后行数」:字节上限决定能解码出多长的文本,行数上限在这一段里再切一次。
|
||||
* 于是两者都超时,[PreviewSlice.truncatedBy] 报 [PreviewTruncation.Bytes]——那才是真正
|
||||
* 先切断它的那一刀。
|
||||
*/
|
||||
fun slice(bytes: ByteArray): PreviewSlice {
|
||||
val totalBytes = bytes.size
|
||||
val totalLines = lineCount(bytes)
|
||||
val byteLimited = totalBytes > DocumentPreviewLimits.MAX_BYTES
|
||||
val prefix = if (byteLimited) {
|
||||
utf8SafePrefixLength(bytes, DocumentPreviewLimits.MAX_BYTES)
|
||||
} else {
|
||||
totalBytes
|
||||
}
|
||||
var text = String(bytes, 0, prefix, Charsets.UTF_8)
|
||||
val lineLimited = lineCount(text) > DocumentPreviewLimits.MAX_LINES
|
||||
if (lineLimited) text = takeLines(text, DocumentPreviewLimits.MAX_LINES)
|
||||
return PreviewSlice(
|
||||
text = text,
|
||||
truncatedBy = when {
|
||||
byteLimited -> PreviewTruncation.Bytes
|
||||
lineLimited -> PreviewTruncation.Lines
|
||||
else -> PreviewTruncation.None
|
||||
},
|
||||
shownBytes = text.toByteArray(Charsets.UTF_8).size,
|
||||
shownLines = lineCount(text),
|
||||
totalBytes = totalBytes,
|
||||
totalLines = totalLines,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按行数分块([DocumentPreviewLimits.LINES_PER_CHUNK] 行一块)。
|
||||
*
|
||||
* 每块**保留自己的换行符**,因此把各块顺序拼回来等于原文——`LazyColumn` 逐块渲染时行距
|
||||
* 不会因为切点而错位。空文本回空列表(UI 不必为它画一个空块)。
|
||||
*/
|
||||
fun chunks(text: String, linesPerChunk: Int = DocumentPreviewLimits.LINES_PER_CHUNK): List<String> {
|
||||
if (text.isEmpty()) return emptyList()
|
||||
if (linesPerChunk <= 0) return listOf(text)
|
||||
val chunks = mutableListOf<String>()
|
||||
var start = 0
|
||||
var lines = 0
|
||||
for (index in text.indices) {
|
||||
if (text[index] != '\n') continue
|
||||
lines++
|
||||
if (lines == linesPerChunk) {
|
||||
chunks += text.substring(start, index + 1)
|
||||
start = index + 1
|
||||
lines = 0
|
||||
}
|
||||
}
|
||||
if (start < text.length) chunks += text.substring(start)
|
||||
return chunks
|
||||
}
|
||||
|
||||
/** 文本行数:`\n` 计数 + 末尾那行(末尾换行符不算额外一行);空文本是 0 行。 */
|
||||
fun lineCount(text: String): Int = when {
|
||||
text.isEmpty() -> 0
|
||||
text.endsWith('\n') -> text.count { it == '\n' }
|
||||
else -> text.count { it == '\n' } + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 字节层面的行数——**不解码整份文件**就能算出行数,因此 [slice] 报「共多少行」时
|
||||
* 不必把 20 MiB 全文变成一个 `String`。
|
||||
*
|
||||
* 直接数 `0x0A`:UTF-8 的续字节(`0x80..0xBF`)与各多字节序列里都不可能出现 `0x0A`,
|
||||
* 所以字节计数与字符计数一致。
|
||||
*/
|
||||
fun lineCount(bytes: ByteArray, length: Int = bytes.size): Int {
|
||||
if (length <= 0) return 0
|
||||
var newlines = 0
|
||||
for (index in 0 until length) {
|
||||
if ((bytes[index].toInt() and 0xFF) == NEWLINE) newlines++
|
||||
}
|
||||
return if ((bytes[length - 1].toInt() and 0xFF) == NEWLINE) newlines else newlines + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 [limit] 处取前截断时,把末尾一个**被切成两半的多字节 UTF-8 字符**退回。
|
||||
*
|
||||
* 不退的后果是正文末尾多出一个 `U+FFFD`(`String(bytes, UTF_8)` 的替换字符)——看着像
|
||||
* 文件本身就是坏的,而实际上是本端自己切坏的。退回最多 3 字节即可(UTF-8 最长 4 字节)。
|
||||
*
|
||||
* @return 可以安全解码的字节数(≤ [limit])。输入本身不合法(整段都是续字节)时原样返回
|
||||
* [limit]——不替一个坏文件找补。
|
||||
*/
|
||||
fun utf8SafePrefixLength(bytes: ByteArray, limit: Int): Int {
|
||||
if (limit >= bytes.size) return bytes.size
|
||||
if (limit <= 0) return 0
|
||||
var lead = limit
|
||||
var continuation = 0
|
||||
while (
|
||||
lead > 0 &&
|
||||
continuation < UTF8_MAX_BYTES - 1 &&
|
||||
(bytes[lead - 1].toInt() and 0xC0) == UTF8_CONTINUATION
|
||||
) {
|
||||
lead--
|
||||
continuation++
|
||||
}
|
||||
if (lead == 0) return limit
|
||||
val expected = when (bytes[lead - 1].toInt() and 0xFF) {
|
||||
in 0x00..0x7F -> 1
|
||||
in 0xC0..0xDF -> 2
|
||||
in 0xE0..0xEF -> 3
|
||||
in 0xF0..0xF7 -> 4
|
||||
else -> 1
|
||||
}
|
||||
val available = limit - lead + 1
|
||||
return if (available < expected) lead - 1 else limit
|
||||
}
|
||||
|
||||
/** 取前 [count] 行(含第 [count] 行的换行符);不足 [count] 行时原样返回。 */
|
||||
private fun takeLines(text: String, count: Int): String {
|
||||
if (count <= 0) return ""
|
||||
var seen = 0
|
||||
for (index in text.indices) {
|
||||
if (text[index] != '\n') continue
|
||||
seen++
|
||||
if (seen == count) return text.substring(0, index + 1)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private const val NEWLINE = 0x0A
|
||||
private const val UTF8_MAX_BYTES = 4
|
||||
private const val UTF8_CONTINUATION = 0x80
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览取字节失败时的文案(纯函数,可单测)。
|
||||
*
|
||||
* 与 [top.yeij.cyrene.ui.chat.documentUploadFailureMessage] 同一纪律:服务端能说清的就用它
|
||||
* 的原话(含真实原因),本地能分类的就分类说,**绝不把失败渲染成「空文件」**。
|
||||
*
|
||||
* 三条本地失败必须分开,因为它们要用户做的事不一样:读不到 = 重新选一份;太大 = 换一份;
|
||||
* 缓存写不进去 = 这是本端的问题,重试多半还是同样的结果。
|
||||
*/
|
||||
fun documentLoadFailureMessage(e: Throwable): String = when (e) {
|
||||
is LocalDocumentUnavailableException -> "读不到这份文件,请重新选择"
|
||||
is LocalDocumentTooLargeException -> "这份文件超过 ${DocumentAttachments.maxFileSizeLabel},无法预览"
|
||||
is DocumentCacheUnavailableException -> "本地缓存不可用,暂时无法预览这份附件"
|
||||
else -> when (val failure = e.toDocumentFailure()) {
|
||||
// 服务端对「名字非法」与「文件不存在」刻意同码(不泄露差异)——本端只说能确定的那半句。
|
||||
is DocumentFailure.NotFound -> "服务端上已经没有这份文件了"
|
||||
is DocumentFailure.Other -> "附件加载失败," + failure.message
|
||||
else -> failure.message
|
||||
}
|
||||
}
|
||||
|
||||
/** 千分位分组(只用于行数这类计数,纯展示)。 */
|
||||
private fun grouped(value: Int): String = String.format(Locale.ROOT, "%,d", value)
|
||||
Reference in new issue
Block a user