merge: W11 — 附件芯片可点开预览 + 「用其它应用打开 / 保存到下载目录」两条出口(FileProvider)

This commit is contained in:
AskaEth committed 2026-10-04 19:22:25 +08:00
commit 60b0eacafd
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)