diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7c77b59..e775529 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -48,6 +48,23 @@
+
+
+
+
+
diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/DocumentCache.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/DocumentCache.kt
new file mode 100644
index 0000000..fe510bc
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/DocumentCache.kt
@@ -0,0 +1,219 @@
+package top.yeij.cyrene.data.repository
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import top.yeij.cyrene.core.error.CyreneError
+import top.yeij.cyrene.data.api.CyreneException
+import top.yeij.cyrene.domain.chat.DocumentAttachments
+import top.yeij.cyrene.domain.chat.DocumentSource
+import top.yeij.cyrene.domain.chat.readBounded
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.nio.file.AtomicMoveNotSupportedException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import java.security.MessageDigest
+
+/** 待发附件的本地读取失败(授权失效 / 文件已被删 / provider 报错)。 */
+class LocalDocumentUnavailableException : Exception("local document unreadable")
+
+/** 待发附件超过本端能处理的体积上限(服务端另有 `413 document_too_large`)。 */
+class LocalDocumentTooLargeException : Exception("local document too large")
+
+/** 缓存目录写不进去(连预览都做不了——字节拿到了也没地方交给别的 App)。 */
+class DocumentCacheUnavailableException : Exception("document cache unavailable")
+
+/**
+ * `cacheDir/documents/` 的路径推导、命中判定与原子落盘(纯逻辑 + 文件操作,可单测)。
+ *
+ * ## 为什么命中就等于「永远有效」
+ * 服务端落盘名是**内容寻址**的(`sha256(bytes)[:32] + ext`,契约 v0.59 §1.2 ①),且读侧响应带
+ * `Cache-Control: private, max-age=86400, immutable`(§1.2 ②)。名字由内容决定 ⇒ **同一个
+ * `name` 的字节永不改变**。所以这里不做任何过期 / 校验 / 大小比对:文件在且非空就是命中。
+ * 反过来说,服务端就算把源文件删了,本地这份也仍是当初的那一份内容(这正是缓存的意义)。
+ *
+ * ## 为什么必须原子落盘
+ * 下载中途失败(断网 / 进程被杀)会留下一个**截断的文件**。如果它落在正式名下,下一次打开
+ * 就会「命中」并把这个半截文件当成完整内容显示——**静默的错误内容比一次失败糟得多**。
+ * 因此先写 `.part` 临时文件,成功了再改名;任何一步失败都把临时文件删掉。
+ */
+object DocumentCache {
+
+ /** FileProvider(见 `AndroidManifest.xml` 与 `res/xml/file_paths.xml`)暴露的那一层目录名。 */
+ const val DIRECTORY_NAME: String = "documents"
+
+ /** 缓存目录:`/documents`。 */
+ fun directory(cacheDir: File): File = File(cacheDir, DIRECTORY_NAME)
+
+ /**
+ * 某个名字在缓存里的路径;名字**不安全**时回 `null`。
+ *
+ * 缓存目录是唯一被 FileProvider 暴露出去的根,所以这里对名字的容忍度取最小:单段、
+ * 非空、不是 `.` / `..`、不含分隔符与 NUL。服务端形态(`<32hex>.`)与
+ * [localName] 生成的形态都天然满足;不满足的名字一律拒绝,绝不用它拼出一条能爬出
+ * `documents/` 的路径。
+ */
+ fun fileFor(cacheDir: File, name: String): File? =
+ if (isSafeName(name)) File(directory(cacheDir), name) else null
+
+ /** 名字是否可以安全地当**单个**文件名用。 */
+ fun isSafeName(name: String): Boolean =
+ name.isNotBlank() &&
+ name != "." &&
+ name != ".." &&
+ name.none { it == '/' || it == '\\' || it == '\u0000' }
+
+ /** 命中判定:**非空文件**才算。0 字节(失败落盘 / 被外部清空的残留)一律当未命中。 */
+ fun isHit(file: File): Boolean = file.isFile && file.length() > 0L
+
+ /**
+ * 原子落盘:写临时文件 → 改名到 [file]。失败时**不留半截文件**在任何正式名下。
+ *
+ * @throws IOException 写不动 / 改名失败(临时文件已清掉)。
+ */
+ fun write(file: File, bytes: ByteArray) {
+ val directory = file.parentFile ?: throw IOException("cache file has no parent: $file")
+ if (!directory.isDirectory && !directory.mkdirs()) {
+ throw IOException("cannot create cache directory: $directory")
+ }
+ val temporary = File.createTempFile(TEMP_PREFIX, TEMP_SUFFIX, directory)
+ try {
+ temporary.outputStream().use { it.write(bytes) }
+ moveInto(temporary, file)
+ } catch (e: Exception) {
+ temporary.delete()
+ throw e
+ } finally {
+ // 成功时临时文件已被改名(不再存在);失败时这里再兜一次,别把 .part 留在缓存里。
+ if (temporary.exists()) temporary.delete()
+ }
+ }
+
+ /**
+ * 本地待发附件的缓存名:**也按内容寻址**(`sha256(bytes)[:32] + 扩展名`)。
+ *
+ * 复用服务端那条规则有两个好处:同一份字节重复打开只落一份;名字形态与服务端存储名同构,
+ * 「打开 / 保存」两条路不必分支处理。扩展名取自待发附件的显示名(本地这一侧它是可信的,
+ * 用户选的就是它)——拿不到就用空扩展名,**不猜**。
+ */
+ fun localName(bytes: ByteArray, displayName: String): String =
+ sha256Hex(bytes).take(NAME_HEX_LENGTH) + extensionOf(displayName)
+
+ private fun moveInto(from: File, to: File) {
+ try {
+ Files.move(
+ from.toPath(),
+ to.toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ StandardCopyOption.ATOMIC_MOVE,
+ )
+ } catch (_: AtomicMoveNotSupportedException) {
+ Files.move(from.toPath(), to.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ }
+ }
+
+ private fun sha256Hex(bytes: ByteArray): String =
+ MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
+
+ /** 显示名里的扩展名(带点、小写、≤8 个字母数字);拿不到就回空串。 */
+ private fun extensionOf(displayName: String): String {
+ val extension = displayName.substringAfterLast('.', "").lowercase()
+ val usable = extension.isNotEmpty() &&
+ extension.length <= MAX_EXTENSION_LENGTH &&
+ extension.all { it.isLetterOrDigit() }
+ return if (usable) ".$extension" else ""
+ }
+
+ private const val TEMP_PREFIX = "cache-"
+ private const val TEMP_SUFFIX = ".part"
+ private const val NAME_HEX_LENGTH = 32
+ private const val MAX_EXTENSION_LENGTH = 8
+}
+
+/**
+ * 缓存里的一份文档:[file] 是落盘的那一份(交给 FileProvider / 写进下载目录),[bytes] 是它的
+ * 内容(预览切段用)。
+ *
+ * 两者一起返回是刻意的:下载刚拿到的字节与随后写进缓存的是**同一份**,再读一次盘只是白花
+ * 一次 20 MiB 的 IO。
+ */
+class CachedDocument(val file: File, val bytes: ByteArray)
+
+/**
+ * 「按需取字节 + 落盘缓存」的取数层(`GET /api/v1/documents/{name}` 的**第一个调用方**)。
+ *
+ * 服务端([DocumentRepository.download])与本地(待发附件的 `content://`)两条来源在这里汇成
+ * 同一个结果。来源判定的**唯一**落点是 [DocumentSource](见 `DocumentTarget.of`)——本类只按
+ * 它分支,不再自己判断「这是服务端还是本地」。
+ *
+ * [openLocal] 收成一个 `String -> InputStream?`,把 `ContentResolver` 挡在类外:这样本类可以在
+ * 单测里用临时目录 + 假流跑完(Android 的 `Uri` / `ContentResolver` 在 JVM 单测里是 stub,
+ * 一碰就抛)。
+ */
+class DocumentCacheStore(
+ private val repository: DocumentRepository,
+ private val cacheDir: File,
+ private val openLocal: (String) -> InputStream?,
+ private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
+) {
+
+ /**
+ * 取回一份附件的字节并确保它已落进缓存。
+ *
+ * 服务端来源先看缓存([DocumentCache.isHit])——命中就**一个请求都不发**;本地来源没有
+ * 「命中」可言(URI 指向的内容可能变),每次都按当前字节重新算内容寻址名。
+ */
+ suspend fun load(source: DocumentSource): Result = withContext(dispatcher) {
+ when (source) {
+ is DocumentSource.Server -> loadServer(source.name)
+ is DocumentSource.Local -> loadLocal(source)
+ }
+ }
+
+ private suspend fun loadServer(name: String): Result {
+ val file = DocumentCache.fileFor(cacheDir, name)
+ // 名字不安全 = 服务端给了一个本端不敢拼路径的名字;判解析失败,绝不退回本地文件名。
+ ?: return Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
+ if (DocumentCache.isHit(file)) {
+ val cached = runCatching { file.readBytes() }.getOrNull()
+ if (cached != null) return Result.success(CachedDocument(file, cached))
+ // 缓存读不动(被外部清理 / 权限异常):删掉重下,绝不当成「空文件」返回。
+ file.delete()
+ }
+ val bytes = repository.download(name).getOrElse { return Result.failure(it) }
+ return cache(file, bytes)
+ }
+
+ private fun loadLocal(source: DocumentSource.Local): Result {
+ val bytes = try {
+ val input = openLocal(source.uri)
+ ?: return Result.failure(LocalDocumentUnavailableException())
+ input.use { stream ->
+ // 与上传路径同一把尺子:本端能处理的体积上限就是附件上限。
+ readBounded(stream, DocumentAttachments.MAX_FILE_BYTES)
+ } ?: return Result.failure(LocalDocumentTooLargeException())
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ return Result.failure(LocalDocumentUnavailableException())
+ }
+ val file = DocumentCache.fileFor(cacheDir, DocumentCache.localName(bytes, source.displayName))
+ ?: return Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
+ // 内容寻址:同一份字节已经落过盘就不重写(重复打开一份大附件不该反复写 20 MiB)。
+ if (!DocumentCache.isHit(file)) return cache(file, bytes)
+ return Result.success(CachedDocument(file, bytes))
+ }
+
+ private fun cache(file: File, bytes: ByteArray): Result = try {
+ DocumentCache.write(file, bytes)
+ Result.success(CachedDocument(file, bytes))
+ } catch (_: Exception) {
+ // 写不进去时把可能的残留清掉,再如实报「缓存不可用」——预览本身也是靠缓存文件那条路
+ // 把完整内容交给别的 App 的,所以这里不回退成「只看得到一半」。
+ file.delete()
+ Result.failure(DocumentCacheUnavailableException())
+ }
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentPreview.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentPreview.kt
new file mode 100644
index 0000000..60d4125
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentPreview.kt
@@ -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>.`,**绝不**替用户编一个「用户当初叫它什么」的假名字(那会是本端凭空的
+ * 信息)。本地待发附件倒是还有用户当初选的那份显示名。
+ */
+ 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 {
+ if (text.isEmpty()) return emptyList()
+ if (linesPerChunk <= 0) return listOf(text)
+ val chunks = mutableListOf()
+ 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)
diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt
index 7ce6dc5..7acc3d2 100644
--- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt
+++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt
@@ -1,5 +1,10 @@
package top.yeij.cyrene.ui.chat
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
@@ -28,27 +33,42 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
+import androidx.core.content.FileProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import coil3.compose.AsyncImage
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.app.ShareIntake
+import top.yeij.cyrene.data.repository.DocumentCacheStore
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.chat.DocumentChips
+import top.yeij.cyrene.domain.chat.DocumentOpen
+import top.yeij.cyrene.domain.chat.DocumentOpenPlan
+import top.yeij.cyrene.domain.chat.DocumentOpenTypes
+import top.yeij.cyrene.domain.chat.DocumentPreview
+import top.yeij.cyrene.domain.chat.DocumentSource
+import top.yeij.cyrene.domain.chat.DocumentTarget
import top.yeij.cyrene.domain.chat.DraftDocument
+import top.yeij.cyrene.domain.chat.documentLoadFailureMessage
import top.yeij.cyrene.domain.share.ShareRules
import top.yeij.cyrene.domain.share.SharedContent
import top.yukonga.miuix.kmp.basic.Button
@@ -113,6 +133,101 @@ fun ChatScreen(
val state by viewModel.state.collectAsState()
val listState = rememberLazyListState()
+ // —— 附件预览 ——
+ // 取字节的缓存层:服务端文档按存储名先看缓存、待发附件先读本地 URI,两条来源汇成同一份
+ // 「缓存里的文件 + 字节」。`openLocal` 把 ContentResolver 收在这里,类本身可单测。
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val documentCache = remember(container) {
+ DocumentCacheStore(
+ repository = container.documentRepository,
+ cacheDir = container.appContext.cacheDir,
+ openLocal = { uri ->
+ runCatching {
+ container.appContext.contentResolver.openInputStream(Uri.parse(uri))
+ }.getOrNull()
+ },
+ )
+ }
+ var previewTarget by remember { mutableStateOf(null) }
+ // 「用其它应用打开 / 保存」这一步的如实告知(没有 App 能开、保存成功或失败),进弹层显示。
+ var previewNotice by remember { mutableStateOf(null) }
+ var previewReload by remember { mutableStateOf(0) }
+
+ val previewState by produceState(
+ initialValue = DocumentPreviewState.Loading,
+ key1 = previewTarget,
+ key2 = previewReload,
+ ) {
+ val target = previewTarget ?: return@produceState
+ value = DocumentPreviewState.Loading
+ value = documentCache.load(target.source).fold(
+ onSuccess = { document ->
+ // 只把前 256 KB / 5000 行交给 UI(截断由 PreviewSlice.notice 明写出来)。
+ DocumentPreviewState.Ready(document, DocumentPreview.slice(document.bytes))
+ },
+ onFailure = { DocumentPreviewState.Failed(documentLoadFailureMessage(it)) },
+ )
+ }
+
+ val readyPreview = previewState as? DocumentPreviewState.Ready
+ // 类型只解析一次(本地来源要问 ContentResolver,那是主线程上的一次 IPC,不该每次重组都做)。
+ val previewType = remember(previewTarget) {
+ previewTarget?.let { documentOpenType(context, it) } ?: DocumentOpenTypes.OCTET_STREAM
+ }
+ val previewFileName = previewTarget?.suggestedFileName() ?: FALLBACK_PREVIEW_FILE_NAME
+
+ // 「保存到下载目录」走系统 SAF:用户自己挑落点,本端不申请任何存储权限。
+ // 契约带上的 MIME 用文档自己的类型(服务端嗅探所得,不按扩展名猜)。
+ val createDocument = rememberLauncherForActivityResult(
+ remember(previewType) { ActivityResultContracts.CreateDocument(previewType) },
+ ) { destination ->
+ val ready = previewState as? DocumentPreviewState.Ready
+ if (destination == null || ready == null) return@rememberLauncherForActivityResult
+ scope.launch {
+ val saved = withContext(Dispatchers.IO) {
+ runCatching {
+ context.contentResolver.openOutputStream(destination)?.use {
+ it.write(ready.document.bytes)
+ } ?: error("no output stream for $destination")
+ }.isSuccess
+ }
+ previewNotice = if (saved) {
+ DocumentPreviewNotice("已保存")
+ } else {
+ DocumentPreviewNotice("保存失败,请换一个位置再试", error = true)
+ }
+ }
+ }
+
+ // 「用其它应用打开」:把缓存里的那一份经 FileProvider 交给系统。
+ // 没有任何 App 能处理时必须如实告知(并指路保存),**绝不静默失败**。
+ fun openPreviewDocument() {
+ val ready = readyPreview ?: return
+ val contentUri = runCatching {
+ FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", ready.document.file)
+ }.getOrNull()
+ if (contentUri == null) {
+ previewNotice = DocumentPreviewNotice("无法把这份文件交给其它应用:本地缓存文件不存在", error = true)
+ return
+ }
+ val intent = Intent(Intent.ACTION_VIEW)
+ .setDataAndType(contentUri, previewType)
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ val hasHandler = context.packageManager
+ .resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null
+ when (val plan = DocumentOpen.plan(previewType, hasHandler)) {
+ DocumentOpenPlan.HandOff -> try {
+ context.startActivity(intent)
+ } catch (_: ActivityNotFoundException) {
+ // 解析到 handler 与实际启动之间仍可能落空(App 被卸载 / 被禁用),走同一条告知。
+ previewNotice = DocumentPreviewNotice(DocumentOpen.noHandlerMessage(previewType), error = true)
+ }
+
+ is DocumentOpenPlan.NoHandler -> previewNotice = DocumentPreviewNotice(plan.message, error = true)
+ }
+ }
+
// 系统相册选择器(PickVisualMedia 走 Photo Picker,**不需要任何存储权限**)。
// 只把 URI 交给 VM:真正的解码 / 缩放 / base64 在发送时于 IO 线程做。
val imagePicker = rememberLauncherForActivityResult(
@@ -206,6 +321,12 @@ fun ChatScreen(
onClearImage = viewModel::clearDraftImage,
onPickDocument = { documentPicker.launch(DocumentPickerMimeTypes) },
onRemoveDocument = viewModel::removeDraftDocument,
+ onPreviewDocument = { index ->
+ state.draftDocuments.getOrNull(index)?.let { draft ->
+ previewNotice = null
+ previewTarget = DocumentTarget.of(draft)
+ }
+ },
)
},
) { padding ->
@@ -226,8 +347,22 @@ fun ChatScreen(
) {
items(items = state.items.asReversed(), key = { it.key }) { item ->
when (item) {
- is ChatDisplayItem.Committed -> MessageBubble(item.message)
- is ChatDisplayItem.Pending -> PendingBubble(item.item)
+ is ChatDisplayItem.Committed -> MessageBubble(
+ message = item.message,
+ onDocumentClick = { ref ->
+ previewNotice = null
+ previewTarget = DocumentTarget.of(ref)
+ },
+ )
+
+ is ChatDisplayItem.Pending -> PendingBubble(
+ item = item.item,
+ onDocumentClick = { draft ->
+ previewNotice = null
+ previewTarget = DocumentTarget.of(draft)
+ },
+ )
+
ChatDisplayItem.Streaming -> state.streaming?.let { StreamingBubble(it) }
}
}
@@ -267,8 +402,40 @@ fun ChatScreen(
}
}
}
+
+ previewTarget?.let { target ->
+ DocumentPreviewDialog(
+ target = target,
+ state = previewState,
+ notice = previewNotice,
+ onRetry = { previewReload++ },
+ onOpen = { openPreviewDocument() },
+ onSave = { createDocument.launch(previewFileName) },
+ onDismiss = {
+ previewTarget = null
+ previewNotice = null
+ },
+ )
+ }
}
+/**
+ * 「打开 / 保存」要声明的 MIME。
+ *
+ * 服务端来源直接取 DTO 里那个**字节嗅探**所得的 `mime`;本地待发附件只能问平台
+ * (`ContentResolver.getType`,那是**声明**而不是裁决)。**两条路都不按扩展名猜**,
+ * 见 [DocumentTarget.openType] 与 [DocumentOpenTypes]。
+ */
+private fun documentOpenType(context: Context, target: DocumentTarget): String {
+ val declared = (target.source as? DocumentSource.Local)?.let { source ->
+ runCatching { context.contentResolver.getType(Uri.parse(source.uri)) }.getOrNull()
+ }
+ return target.openType(declared)
+}
+
+/** 建议文件名兜底:理论上到不了(服务端的存储名恒非空),只是不让一个空名字进 SAF。 */
+private const val FALLBACK_PREVIEW_FILE_NAME = "document.txt"
+
/** 消息区上方的一行提示:错误与分享告知共用同一形态(铺满宽度、卡片底色)。 */
@Composable
private fun Banner(text: String, color: Color) {
@@ -295,6 +462,7 @@ private fun InputBar(
onClearImage: () -> Unit,
onPickDocument: () -> Unit,
onRemoveDocument: (Int) -> Unit,
+ onPreviewDocument: (Int) -> Unit,
) {
Column(
modifier = Modifier
@@ -304,7 +472,12 @@ private fun InputBar(
) {
imageUri?.let { DraftImagePreview(uri = it, onClear = onClearImage) }
// 待发附件:输入栏上方的一排可移除芯片(顺序即发送顺序,服务端也按这个顺序存)。
- DraftDocumentChips(documents = documents, onRemove = onRemoveDocument)
+ // 整枚也可点:点开的是**本地**那一份预览(还没上传,还没有服务端存储名)。
+ DraftDocumentChips(
+ documents = documents,
+ onRemove = onRemoveDocument,
+ onClick = onPreviewDocument,
+ )
Row(verticalAlignment = Alignment.Bottom) {
// 图片入口只在没有待发图片时出现:已经选好一张就没必要再添一张(一次一条消息一张图)。
if (imageUri == null) {
@@ -365,10 +538,15 @@ private fun InputBar(
/** 待发附件的芯片排:换行排布(5 枚最多占 3 行;宁可占点高度,也不把附件藏进横向滚动里)。 */
@Composable
-private fun DraftDocumentChips(documents: List, onRemove: (Int) -> Unit) {
+private fun DraftDocumentChips(
+ documents: List,
+ onRemove: (Int) -> Unit,
+ onClick: (Int) -> Unit,
+) {
DocumentChipRow(
chips = documents.map(DocumentChips::of),
onRemove = onRemove,
+ onClick = onClick,
modifier = Modifier.padding(bottom = 6.dp),
)
}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/DocumentPreviewDialog.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/DocumentPreviewDialog.kt
new file mode 100644
index 0000000..d3bd1ee
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/DocumentPreviewDialog.kt
@@ -0,0 +1,220 @@
+package top.yeij.cyrene.ui.chat
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+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.DocumentTarget
+import top.yeij.cyrene.domain.chat.PreviewSlice
+import top.yeij.cyrene.ui.common.PreferenceCard
+import top.yukonga.miuix.kmp.basic.BasicComponent
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.basic.TextButton
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+
+/** 预览弹层的三种状态。**加载中与失败必须长得不一样**:失败要能重试,还绝不能被当成「空文件」。 */
+sealed interface DocumentPreviewState {
+
+ data object Loading : DocumentPreviewState
+
+ /** 取字节失败;[message] 是 [top.yeij.cyrene.domain.chat.documentLoadFailureMessage] 的产物。 */
+ data class Failed(val message: String) : DocumentPreviewState
+
+ /** 已取到字节并切好段。[document] 同时是「打开 / 保存」要用的那一份。 */
+ data class Ready(val document: CachedDocument, val slice: PreviewSlice) : DocumentPreviewState
+}
+
+/**
+ * 弹层下方的一行告知。
+ *
+ * [error] 单独一位是因为「已保存」与「没有应用能打开」是**同一行**上的两种性质:都用错误色
+ * 会让一次成功的保存看着像出事,都用中性色又会让失败被当成提示。
+ */
+data class DocumentPreviewNotice(val message: String, val error: Boolean = false)
+
+/**
+ * 附件预览弹层。
+ *
+ * 用 Compose 的 `Dialog` + Miuix 内容,与抽屉里那几个弹层同一套做法(抽屉只有 300dp 宽,
+ * 而正文要一块能盖住全屏、可滚动的地方)。
+ *
+ * ## 三条出口
+ * - **关闭**:只关弹层,什么都不写;
+ * - **用其它应用打开**:把缓存里的那一份经 `FileProvider` 交给系统([onOpen]);
+ * - **保存到下载目录**:走 `ACTION_CREATE_DOCUMENT` 让用户挑落点([onSave])。
+ *
+ * 后两条在字节还没到位([DocumentPreviewState.Loading] / [DocumentPreviewState.Failed])时
+ * **不建可点**:点了也只能失败,不如让它们明摆着还没准备好。
+ *
+ * ## 正文为什么是 `LazyColumn` + 分块
+ * 上限是 5000 行 / 256 KB,整段塞进一个 `Text` 会让打开弹层的那一刻卡在排版上。切成
+ * [top.yeij.cyrene.domain.chat.DocumentPreviewLimits.LINES_PER_CHUNK] 行一块交给 `LazyColumn`,
+ * 就只测量当前可见的那几块。每块各自包一层 `SelectionContainer`,与气泡同一约定:
+ * 「全选」只作用于当前那一块,不跨块。
+ *
+ * ## 截断必须自己说出来
+ * [PreviewSlice.notice] 非空时在正文上方明写「只显示前 N」并指路另外两条出口——**绝不静默
+ * 截断**:用户看到一段像是完整的正文却其实是前 256 KB,是最容易被误读的一种失败。
+ *
+ * @param target 被点开的那一枚芯片(来源判定见 [DocumentTarget.of],弹层不自己判断)。
+ * @param notice 「用其它应用打开 / 保存」这一步的如实告知(没有 App 能开、保存成功或失败);
+ * null = 没什么要说的。
+ */
+@Composable
+fun DocumentPreviewDialog(
+ target: DocumentTarget,
+ state: DocumentPreviewState,
+ notice: DocumentPreviewNotice?,
+ onRetry: () -> Unit,
+ onOpen: () -> Unit,
+ onSave: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val enabled = state is DocumentPreviewState.Ready
+
+ Dialog(onDismissRequest = onDismiss) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp))
+ .padding(20.dp),
+ ) {
+ Text("附件预览", style = MiuixTheme.textStyles.title3)
+ Spacer(Modifier.height(8.dp))
+ // 文件名(服务端存储名或待发附件的显示名),长名字中略——扩展名是这里唯一有语义的部分。
+ Text(
+ text = target.chips.name,
+ style = MiuixTheme.textStyles.footnote1,
+ color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
+ maxLines = 1,
+ overflow = TextOverflow.MiddleEllipsis,
+ )
+ Spacer(Modifier.height(6.dp))
+ // 类型 · 体积 · 字数 · 状态:**复用气泡那枚芯片**,不另写一套标签。
+ DocumentChipRow(chips = listOf(target.chips))
+ Spacer(Modifier.height(12.dp))
+
+ when (state) {
+ DocumentPreviewState.Loading -> PreviewHint("正在取回内容…")
+
+ is DocumentPreviewState.Failed -> {
+ // 失败**绝不渲染成空文件**:一句话说清 + 一个重试出口。
+ PreviewHint(state.message, error = true)
+ Spacer(Modifier.height(8.dp))
+ 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),
+ )
+ }
+ PreviewBody(state.slice)
+ }
+ }
+
+ notice?.let { line ->
+ Spacer(Modifier.height(10.dp))
+ Text(
+ text = line.message,
+ style = MiuixTheme.textStyles.footnote2,
+ color = if (line.error) {
+ MiuixTheme.colorScheme.error
+ } else {
+ MiuixTheme.colorScheme.onSurfaceVariantSummary
+ },
+ )
+ }
+
+ Spacer(Modifier.height(16.dp))
+ PreferenceCard {
+ BasicComponent(
+ title = "用其它应用打开",
+ summary = "交给系统里能读这个类型的应用",
+ onClick = if (enabled) onOpen else null,
+ )
+ }
+ Spacer(Modifier.height(8.dp))
+ PreferenceCard {
+ BasicComponent(
+ title = "保存到下载目录",
+ summary = "自己挑一个落点,内容与这里看到的是同一份",
+ onClick = if (enabled) onSave else null,
+ )
+ }
+ Spacer(Modifier.height(10.dp))
+ // 契约缺口如实写在界面上:服务端按内容寻址落盘,只回存储名,原始文件名不可得。
+ Text(
+ text = "服务端只保留了内容寻址的存储名,保存下来的文件名就是它,原始文件名不会返回给客户端。",
+ style = MiuixTheme.textStyles.footnote2,
+ color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
+ )
+
+ Spacer(Modifier.height(16.dp))
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ TextButton("关闭", onClick = onDismiss)
+ }
+ }
+ }
+}
+
+/** 正文区:按行分块、逐块可选中,块数上限由 [DocumentPreviewLimits] 保证。 */
+@Composable
+private fun PreviewBody(slice: PreviewSlice) {
+ val chunks = remember(slice.text) { DocumentPreview.chunks(slice.text) }
+ if (chunks.isEmpty()) {
+ PreviewHint("(这份文件是空的)")
+ return
+ }
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = PREVIEW_BODY_MAX_HEIGHT)
+ .background(MiuixTheme.colorScheme.surfaceContainer, RoundedCornerShape(12.dp))
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ ) {
+ // 键必须是下标:内容相同的两块(整篇都是同一行的那种文件)用内容当键会撞键。
+ itemsIndexed(items = chunks, key = { index, _ -> index }) { _, chunk ->
+ SelectionContainer {
+ Text(
+ text = chunk,
+ style = MiuixTheme.textStyles.footnote1,
+ color = MiuixTheme.colorScheme.onSurfaceContainer,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PreviewHint(text: String, error: Boolean = false) {
+ Text(
+ text = text,
+ style = MiuixTheme.textStyles.footnote1,
+ color = if (error) MiuixTheme.colorScheme.error else MiuixTheme.colorScheme.onSurfaceVariantSummary,
+ )
+}
+
+/** 正文区最大高度:再高就把三条出口挤出窄屏的可见区。 */
+private val PREVIEW_BODY_MAX_HEIGHT = 320.dp
diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt
index 0996c50..ab3f699 100644
--- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt
+++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt
@@ -1,6 +1,7 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -17,6 +18,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -26,6 +29,7 @@ import top.yeij.cyrene.data.api.dto.DocumentRefDto
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.chat.DocumentChipModel
import top.yeij.cyrene.domain.chat.DocumentChips
+import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.ParsedBubble
@@ -53,7 +57,7 @@ private const val CURSOR = "▍"
* 因此选择菜单的「全选」只作用于当前那一块,而不是整个页面。
*/
@Composable
-fun MessageBubble(message: HistoryMessageDto) {
+fun MessageBubble(message: HistoryMessageDto, onDocumentClick: (DocumentRefDto) -> Unit = {}) {
val isUser = message.role == "user"
// 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同:
// 表只能给「id → 标签」,说不出哪个 mode 是「按 markup 切气泡的聊天式渲染」,
@@ -66,7 +70,11 @@ fun MessageBubble(message: HistoryMessageDto) {
MessageGroup(isUser = isUser, footer = messageSourceLabel(message, LocalEnumCatalog.current)) {
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
- DocumentChipRow(chips = message.documents.map(DocumentChips::of), alignEnd = isUser)
+ DocumentChipRow(
+ chips = message.documents.map(DocumentChips::of),
+ alignEnd = isUser,
+ onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) },
+ )
message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
bubbles.forEachIndexed { index, bubble ->
Bubble(bubble, isUser, isLast = index == bubbles.lastIndex)
@@ -82,9 +90,13 @@ fun MessageBubble(message: HistoryMessageDto) {
* 附件同理显示**本地显示名**(那时还没有服务端存储名,也还没有任何服务端结论)。
*/
@Composable
-fun PendingBubble(item: PendingMessage) {
+fun PendingBubble(item: PendingMessage, onDocumentClick: (DraftDocument) -> Unit = {}) {
MessageGroup(isUser = true, footer = "发送中…") {
- DocumentChipRow(chips = item.documents.map(DocumentChips::of), alignEnd = true)
+ DocumentChipRow(
+ chips = item.documents.map(DocumentChips::of),
+ alignEnd = true,
+ onClick = { index -> item.documents.getOrNull(index)?.let(onDocumentClick) },
+ )
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
}
@@ -100,6 +112,8 @@ fun PendingBubble(item: PendingMessage) {
*
* [alignEnd] = 用户消息(气泡在右侧):整排贴右;否则一枚短芯片会贴在该排左端、看着像错位。
* [onRemove] 非空即带移除按钮——待发位(输入栏上方)要能反悔,历史气泡里则不给。
+ * [onClick] 非空即整枚可点(附件预览的入口)。**点击区就是这一枚芯片本身**(`clip` + `background`
+ * 那个 `Row`),不是里面的两行文字——否则手指落在芯片的空白处就没有反应。
*
* 附件可能是空列表(绝大多数消息都是),此时**不占位、不留空白**。
*/
@@ -109,6 +123,7 @@ fun DocumentChipRow(
modifier: Modifier = Modifier,
alignEnd: Boolean = false,
onRemove: ((Int) -> Unit)? = null,
+ onClick: ((Int) -> Unit)? = null,
) {
if (chips.isEmpty()) return
FlowRow(
@@ -120,7 +135,11 @@ fun DocumentChipRow(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
chips.forEachIndexed { index, chip ->
- DocumentChip(model = chip, onRemove = onRemove?.let { remove -> { remove(index) } })
+ DocumentChip(
+ model = chip,
+ onRemove = onRemove?.let { remove -> { remove(index) } },
+ onClick = onClick?.let { click -> { click(index) } },
+ )
}
}
}
@@ -133,9 +152,19 @@ fun DocumentChipRow(
* - `truncated:true` = **注入被预算截断**(正文照样进了上下文)→ 只加一句「注入已截断」。
*
* 未知 `status` 原样显示(开放集合,见 [DocumentChips.statusLabel])。
+ *
+ * [onClick] 非空时整枚可点(进附件预览),并带上 `contentDescription`——两行文字各自是可读的,
+ * 但读屏需要知道「点它会发生什么」。
*/
@Composable
-private fun DocumentChip(model: DocumentChipModel, onRemove: (() -> Unit)?) {
+private fun DocumentChip(model: DocumentChipModel, onRemove: (() -> Unit)?, onClick: (() -> Unit)?) {
+ val clickModifier = if (onClick == null) {
+ Modifier
+ } else {
+ Modifier
+ .clickable(onClick = onClick)
+ .semantics { contentDescription = "查看附件 ${model.name}" }
+ }
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
@@ -146,6 +175,7 @@ private fun DocumentChip(model: DocumentChipModel, onRemove: (() -> Unit)?) {
MiuixTheme.colorScheme.surfaceContainerHigh
},
)
+ .then(clickModifier)
.padding(start = 10.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..8659d6d
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/repository/DocumentCacheTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/repository/DocumentCacheTest.kt
new file mode 100644
index 0000000..4dba61c
--- /dev/null
+++ b/app/src/test/kotlin/top/yeij/cyrene/data/repository/DocumentCacheTest.kt
@@ -0,0 +1,260 @@
+package top.yeij.cyrene.data.repository
+
+import kotlinx.coroutines.runBlocking
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.ResponseBody
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import retrofit2.Response
+import top.yeij.cyrene.data.api.ApiEnvelope
+import top.yeij.cyrene.data.api.DocumentsApi
+import top.yeij.cyrene.data.api.dto.DocumentUploadResponse
+import top.yeij.cyrene.domain.chat.DocumentSource
+import java.io.File
+import java.nio.file.Files
+
+/**
+ * 附件缓存的路径推导、命中判定、原子落盘,以及「先看缓存、未命中才下载」这条取字节的路。
+ *
+ * 用**真临时目录** + 一个记账的假 `DocumentsApi`(不是 Android 的 `Context`):缓存这一层的
+ * 全部行为都是文件系统行为,只有把它从 `ContentResolver` / `Uri` 里摘出来才测得到
+ * (那两个类在 JVM 单测里是 stub,一碰就抛)。
+ *
+ * 这里锚的三件事:
+ * 1. **命中 = 永不失效**(服务端存储名是内容寻址的),第二次打开**一个请求都不发**;
+ * 2. **失败绝不留半截文件**(半截文件被下一次「命中」当成完整内容,是静默的错误内容);
+ * 3. 名字不安全时不拼路径(缓存目录是唯一被 FileProvider 暴露出去的根)。
+ */
+class DocumentCacheTest {
+
+ private lateinit var cacheRoot: File
+
+ @Before
+ fun setUp() {
+ cacheRoot = Files.createTempDirectory("cyrene-documents").toFile()
+ }
+
+ @After
+ fun tearDown() {
+ cacheRoot.deleteRecursively()
+ }
+
+ // —— 路径与名字 ——
+
+ @Test
+ fun `the cache lives under cacheDir documents`() {
+ assertEquals(File(cacheRoot, "documents"), DocumentCache.directory(cacheRoot))
+ assertEquals(File(File(cacheRoot, "documents"), NAME), DocumentCache.fileFor(cacheRoot, NAME))
+ }
+
+ @Test
+ fun `an unsafe name never becomes a path`() {
+ assertNull(DocumentCache.fileFor(cacheRoot, ""))
+ assertNull(DocumentCache.fileFor(cacheRoot, " "))
+ assertNull(DocumentCache.fileFor(cacheRoot, "."))
+ assertNull(DocumentCache.fileFor(cacheRoot, ".."))
+ assertNull(DocumentCache.fileFor(cacheRoot, "../escape.txt"))
+ assertNull(DocumentCache.fileFor(cacheRoot, "sub/dir.txt"))
+ assertNull(DocumentCache.fileFor(cacheRoot, "sub\\dir.txt"))
+ assertFalse(DocumentCache.isSafeName("a\u0000b"))
+ assertTrue(DocumentCache.isSafeName(NAME))
+ }
+
+ // —— 命中判定 ——
+
+ @Test
+ fun `a missing or empty file is not a hit`() {
+ val file = DocumentCache.fileFor(cacheRoot, NAME)!!
+
+ assertFalse("还没落盘就不是命中", DocumentCache.isHit(file))
+ file.parentFile!!.mkdirs()
+ file.writeBytes(ByteArray(0))
+ assertFalse("0 字节是失败落盘的残留,不能当完整内容用", DocumentCache.isHit(file))
+ file.writeBytes("hello".toByteArray())
+ assertTrue(DocumentCache.isHit(file))
+ }
+
+ // —— 原子落盘 ——
+
+ @Test
+ fun `a write leaves exactly the target and no part files`() {
+ val file = DocumentCache.fileFor(cacheRoot, NAME)!!
+
+ DocumentCache.write(file, "hello md".toByteArray())
+
+ assertEquals("hello md", file.readText())
+ assertEquals(listOf(NAME), file.parentFile!!.list()!!.toList())
+ }
+
+ @Test
+ fun `a write replaces whatever was there`() {
+ val file = DocumentCache.fileFor(cacheRoot, NAME)!!
+
+ DocumentCache.write(file, "old".toByteArray())
+ DocumentCache.write(file, "new".toByteArray())
+
+ assertEquals("new", file.readText())
+ assertEquals(listOf(NAME), file.parentFile!!.list()!!.toList())
+ }
+
+ @Test
+ fun `a failed write leaves nothing behind`() {
+ // 拿一个**普通文件**当缓存目录:建目录必然失败,因此连临时文件都生不出来。
+ val blocked = File(cacheRoot, "blocked").apply { writeBytes("not a directory".toByteArray()) }
+ val file = File(File(blocked, "documents"), NAME)
+
+ val failed = runCatching { DocumentCache.write(file, "hello".toByteArray()) }.isFailure
+
+ assertTrue("写不进去必须抛,不能假装成功", failed)
+ assertFalse("失败的写不能留下任何东西", file.exists())
+ assertFalse("半截临时文件也不许留下", File(blocked, "documents").exists())
+ }
+
+ // —— 内容寻址的本地名 ——
+
+ @Test
+ fun `the local name is content addressed and keeps the extension`() {
+ val bytes = "hello".toByteArray()
+
+ val name = DocumentCache.localName(bytes, "报告.MD")
+
+ assertEquals(32 + 3, name.length)
+ assertEquals(".md", name.takeLast(3))
+ assertEquals(name, DocumentCache.localName(bytes, "报告.MD"))
+ assertTrue(name != DocumentCache.localName("other".toByteArray(), "报告.MD"))
+ }
+
+ @Test
+ fun `a local name without a usable extension still gets one storage shaped name`() {
+ val name = DocumentCache.localName("hello".toByteArray(), "README")
+
+ assertEquals(32, name.length)
+ assertTrue(DocumentCache.isSafeName(name))
+ }
+
+ // —— 取字节:先看缓存 ——
+
+ @Test
+ fun `a server document is downloaded once and then served from cache`() = runBlocking {
+ val api = FakeDocumentsApi { "hello md".toByteArray() }
+ val store = store(api)
+
+ val first = store.load(DocumentSource.Server(NAME, "text/plain")).getOrThrow()
+ val second = store.load(DocumentSource.Server(NAME, "text/plain")).getOrThrow()
+
+ assertEquals("hello md", String(first.bytes))
+ assertEquals("hello md", String(second.bytes))
+ assertEquals("内容寻址 ⇒ 命中即不再发请求", 1, api.downloads.toLong())
+ assertEquals(DocumentCache.fileFor(cacheRoot, NAME), second.file)
+ assertEquals("hello md", second.file.readText())
+ }
+
+ @Test
+ fun `a failed download leaves no half file for the next open to believe`() = runBlocking {
+ val api = FakeDocumentsApi(failWith = 404)
+ val store = store(api)
+
+ val result = store.load(DocumentSource.Server(NAME, "text/plain"))
+
+ assertTrue(result.isFailure)
+ val file = DocumentCache.fileFor(cacheRoot, NAME)!!
+ assertFalse(file.exists())
+ assertFalse("下载失败连缓存目录都不该建", DocumentCache.directory(cacheRoot).exists())
+ }
+
+ @Test
+ fun `a zero byte leftover is treated as a miss and refetched`() = runBlocking {
+ val file = DocumentCache.fileFor(cacheRoot, NAME)!!
+ file.parentFile!!.mkdirs()
+ file.writeBytes(ByteArray(0))
+ val api = FakeDocumentsApi { "fresh".toByteArray() }
+
+ val document = store(api).load(DocumentSource.Server(NAME, "text/plain")).getOrThrow()
+
+ assertEquals("fresh", String(document.bytes))
+ assertEquals(1, api.downloads)
+ }
+
+ @Test
+ fun `a malformed server name is refused instead of being turned into a path`() = runBlocking {
+ val api = FakeDocumentsApi { "hello".toByteArray() }
+
+ val result = store(api).load(DocumentSource.Server("../escape.txt", "text/plain"))
+
+ assertTrue(result.isFailure)
+ assertEquals(0, api.downloads)
+ }
+
+ // —— 取字节:本地待发附件 ——
+
+ @Test
+ fun `a local draft is written content addressed and reused`() = runBlocking {
+ val bytes = "报告正文".toByteArray()
+ val store = store(FakeDocumentsApi { error("本地附件不该发请求") }, local = mapOf(LOCAL_URI to bytes))
+
+ val first = store.load(DocumentSource.Local(LOCAL_URI, "报告.md")).getOrThrow()
+ val second = store.load(DocumentSource.Local(LOCAL_URI, "报告.md")).getOrThrow()
+
+ assertEquals("报告正文", String(first.bytes))
+ assertEquals(DocumentCache.localName(bytes, "报告.md"), first.file.name)
+ assertEquals(first.file, second.file)
+ assertTrue(first.file.isFile)
+ }
+
+ @Test
+ fun `a local file we cannot open is reported as unreadable`() = runBlocking {
+ val store = store(FakeDocumentsApi { error("不该走到网络") }, local = emptyMap())
+
+ val result = store.load(DocumentSource.Local("content://missing/1", "x.md"))
+
+ assertTrue(result.exceptionOrNull() is LocalDocumentUnavailableException)
+ }
+
+ private fun store(
+ api: DocumentsApi,
+ local: Map = emptyMap(),
+ ) = DocumentCacheStore(
+ repository = DocumentRepository(api),
+ cacheDir = cacheRoot,
+ openLocal = { uri -> local[uri]?.inputStream() },
+ )
+
+ /** 只记账 `download`;上传不在本测试的范围。 */
+ private class FakeDocumentsApi(
+ private val failWith: Int? = null,
+ private val bytes: () -> ByteArray = { error("这份夹具没有配响应体") },
+ ) : DocumentsApi {
+
+ var downloads: Int = 0
+ private set
+
+ override suspend fun upload(
+ file: MultipartBody.Part,
+ ): Response> = error("上传不在本测试的范围")
+
+ override suspend fun download(name: String): Response {
+ downloads++
+ val code = failWith
+ if (code != null) {
+ return Response.error(
+ code,
+ """{"error":{"code":"document_not_found","message":"document not found"}}"""
+ .toResponseBody("application/json".toMediaTypeOrNull()),
+ )
+ }
+ return Response.success(bytes().toResponseBody("text/plain".toMediaTypeOrNull()))
+ }
+ }
+
+ private companion object {
+ const val NAME = "0123456789abcdef0123456789abcdef.md"
+ const val LOCAL_URI = "content://doc/1"
+ }
+}
diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentPreviewTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentPreviewTest.kt
new file mode 100644
index 0000000..b227df6
--- /dev/null
+++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentPreviewTest.kt
@@ -0,0 +1,310 @@
+package top.yeij.cyrene.domain.chat
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import top.yeij.cyrene.core.error.CyreneError
+import top.yeij.cyrene.data.api.CyreneException
+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
+
+/**
+ * 附件预览的纯逻辑:截断规则、来源选择、打开用的类型、以及「没有 App 能开」那条分支。
+ *
+ * 这里锚的四件事都是**用户能看见的后果**,不是内部实现:
+ * 1. 截断必须**明写**(阈值边界逐条钉住:正好等于上限 / 超一字节 / 超一行);
+ * 2. 「用其它应用打开」的类型**只用服务端嗅探所得的 `mime`**,绝不按扩展名猜;
+ * 3. 没有任何 App 能开时**如实告知**并指路保存,**不静默失败**;
+ * 4. 来源判定只有一处([DocumentTarget.of]),服务端来源**只能用存储名**当文件名。
+ */
+class DocumentPreviewTest {
+
+ private fun ref(
+ name: String = "0123456789abcdef0123456789abcdef.md",
+ mime: String = "text/plain",
+ ) = DocumentRefDto(name = name, mime = mime)
+
+ private fun filler(size: Int) = ByteArray(size) { 'a'.code.toByte() }
+
+ // —— 字节上限 ——
+
+ @Test
+ fun `exactly the byte limit is shown in full`() {
+ val slice = DocumentPreview.slice(filler(DocumentPreviewLimits.MAX_BYTES))
+
+ assertFalse(slice.truncated)
+ assertEquals(PreviewTruncation.None, slice.truncatedBy)
+ assertNull("完整显示时一个字都不说", slice.notice)
+ assertEquals(DocumentPreviewLimits.MAX_BYTES, slice.shownBytes)
+ }
+
+ @Test
+ fun `one byte over the limit is truncated and said so`() {
+ val slice = DocumentPreview.slice(filler(DocumentPreviewLimits.MAX_BYTES + 1))
+
+ assertTrue(slice.truncated)
+ assertEquals(PreviewTruncation.Bytes, slice.truncatedBy)
+ assertEquals(DocumentPreviewLimits.MAX_BYTES, slice.shownBytes)
+ assertEquals(DocumentPreviewLimits.MAX_BYTES + 1, slice.totalBytes)
+ assertTrue(slice.notice!!, slice.notice!!.contains("只显示前 256 KB"))
+ }
+
+ @Test
+ fun `truncation always points at the two exits that give the whole file`() {
+ val notice = DocumentPreview.slice(filler(DocumentPreviewLimits.MAX_BYTES + 1)).notice!!
+
+ assertTrue(notice, notice.contains("只显示前"))
+ assertTrue(notice, notice.contains("用其它应用打开"))
+ assertTrue(notice, notice.contains("保存到下载目录"))
+ }
+
+ // —— 行数上限 ——
+
+ @Test
+ fun `exactly the line limit is shown in full`() {
+ val slice = DocumentPreview.slice(lines(DocumentPreviewLimits.MAX_LINES).toByteArray())
+
+ assertFalse(slice.truncated)
+ assertEquals(DocumentPreviewLimits.MAX_LINES, slice.totalLines)
+ assertEquals(DocumentPreviewLimits.MAX_LINES, slice.shownLines)
+ }
+
+ @Test
+ fun `one line over the limit is truncated and said so`() {
+ val slice = DocumentPreview.slice(lines(DocumentPreviewLimits.MAX_LINES + 1).toByteArray())
+
+ assertTrue(slice.truncated)
+ assertEquals(PreviewTruncation.Lines, slice.truncatedBy)
+ assertEquals(DocumentPreviewLimits.MAX_LINES, slice.shownLines)
+ assertEquals(DocumentPreviewLimits.MAX_LINES + 1, slice.totalLines)
+ assertTrue(slice.notice!!, slice.notice!!.contains("只显示前 5,000 行"))
+ }
+
+ @Test
+ fun `when both limits are hit the byte limit is the one that cut first`() {
+ // 6000 行、每行 60 字节 ≈ 366 KB:字节上限先到,所以理由报字节。
+ val text = buildString { repeat(6000) { append("y".repeat(60)); append('\n') } }
+ val slice = DocumentPreview.slice(text.toByteArray())
+
+ assertTrue(slice.truncated)
+ assertEquals(PreviewTruncation.Bytes, slice.truncatedBy)
+ assertEquals(DocumentPreviewLimits.MAX_BYTES, slice.shownBytes)
+ }
+
+ // —— UTF-8 边界 ——
+
+ @Test
+ fun `the byte cut never splits a multibyte character`() {
+ val bytes = "喵".repeat(DocumentPreviewLimits.MAX_BYTES / 3 + 10).toByteArray()
+
+ val slice = DocumentPreview.slice(bytes)
+
+ assertTrue(slice.truncated)
+ assertFalse("切点不该留下替换字符 U+FFFD", slice.text.contains('\uFFFD'))
+ assertEquals(0, slice.text.toByteArray().size % 3)
+ }
+
+ @Test
+ fun `the safe prefix steps back over an incomplete character`() {
+ val twoByte = "aé".toByteArray()
+
+ assertEquals(3, DocumentPreview.utf8SafePrefixLength(twoByte, 3))
+ assertEquals(1, DocumentPreview.utf8SafePrefixLength(twoByte, 2))
+ assertEquals(1, DocumentPreview.utf8SafePrefixLength(twoByte, 1))
+ }
+
+ @Test
+ fun `a four byte character needs all four bytes`() {
+ val emoji = "😀".toByteArray()
+
+ assertEquals(0, DocumentPreview.utf8SafePrefixLength(emoji, 1))
+ assertEquals(0, DocumentPreview.utf8SafePrefixLength(emoji, 2))
+ assertEquals(0, DocumentPreview.utf8SafePrefixLength(emoji, 3))
+ assertEquals(4, DocumentPreview.utf8SafePrefixLength(emoji, 4))
+ }
+
+ // —— 行数 ——
+
+ @Test
+ fun `a trailing newline is not an extra line`() {
+ assertEquals(0, DocumentPreview.lineCount(""))
+ assertEquals(1, DocumentPreview.lineCount("a"))
+ assertEquals(1, DocumentPreview.lineCount("a\n"))
+ assertEquals(2, DocumentPreview.lineCount("a\nb"))
+ assertEquals(2, DocumentPreview.lineCount("a\nb\n"))
+ }
+
+ @Test
+ fun `the byte level line count agrees with the text one`() {
+ assertEquals(0, DocumentPreview.lineCount(ByteArray(0)))
+ assertEquals(2, DocumentPreview.lineCount("a\nb".toByteArray()))
+ assertEquals(2, DocumentPreview.lineCount("a\nb\n".toByteArray()))
+ }
+
+ // —— 分块 ——
+
+ @Test
+ fun `the body is chunked by lines and joining the chunks returns the text`() {
+ val text = lines(400)
+
+ val chunks = DocumentPreview.chunks(text, DocumentPreviewLimits.LINES_PER_CHUNK)
+
+ assertEquals(2, chunks.size)
+ assertEquals(text, chunks.joinToString(""))
+ }
+
+ @Test
+ fun `a partial last chunk is kept`() {
+ assertEquals(3, DocumentPreview.chunks(lines(401), DocumentPreviewLimits.LINES_PER_CHUNK).size)
+ }
+
+ @Test
+ fun `the whole preview becomes twenty five chunks at most`() {
+ // 5000 行上限 ÷ 200 行一块 = 25 块,LazyColumn 因此最多测 25 个 item。
+ assertEquals(
+ DocumentPreviewLimits.MAX_LINES / DocumentPreviewLimits.LINES_PER_CHUNK,
+ DocumentPreview.chunks(lines(DocumentPreviewLimits.MAX_LINES)).size,
+ )
+ }
+
+ @Test
+ fun `an empty body has no chunks`() {
+ assertEquals(emptyList(), DocumentPreview.chunks(""))
+ }
+
+ // —— 打开用的类型:只认嗅探所得的 mime ——
+
+ @Test
+ fun `the open type is the sniffed mime even when the extension says otherwise`() {
+ // 服务端对 data.json 嗅探回 text/plain——按扩展名猜成 application/json 就是说一个
+ // 服务端刻意不承认的类型。
+ val target = DocumentTarget.of(ref(name = "0123456789abcdef0123456789abcdef.json"))
+
+ assertEquals("text/plain", target.openType())
+ }
+
+ @Test
+ fun `an unknown server type falls back to octet stream instead of guessing`() {
+ assertEquals(DocumentOpenTypes.OCTET_STREAM, DocumentOpenTypes.forServer(""))
+ assertEquals(DocumentOpenTypes.OCTET_STREAM, DocumentOpenTypes.forServer(" "))
+ assertEquals("text/plain", DocumentOpenTypes.forServer("text/plain"))
+ }
+
+ @Test
+ fun `a local file uses the platform declaration and falls back when there is none`() {
+ assertEquals(DocumentOpenTypes.OCTET_STREAM, DocumentOpenTypes.forLocal(null))
+ assertEquals(DocumentOpenTypes.OCTET_STREAM, DocumentOpenTypes.forLocal(""))
+ assertEquals("text/csv", DocumentOpenTypes.forLocal("text/csv"))
+ }
+
+ // —— 没有 App 能开 ——
+
+ @Test
+ fun `no app can open it - we say so and point at saving`() {
+ val plan = DocumentOpen.plan("text/plain", hasHandler = false)
+
+ assertTrue(plan is DocumentOpenPlan.NoHandler)
+ val message = (plan as DocumentOpenPlan.NoHandler).message
+ assertTrue(message, message.contains("没有应用能打开"))
+ assertTrue(message, message.contains("保存到下载目录"))
+ }
+
+ @Test
+ fun `a handler means handing the intent off`() {
+ assertEquals(DocumentOpenPlan.HandOff, DocumentOpen.plan("text/plain", hasHandler = true))
+ }
+
+ @Test
+ fun `the launch fallback says the very same sentence`() {
+ val plan = DocumentOpen.plan("text/plain", hasHandler = false) as DocumentOpenPlan.NoHandler
+
+ assertEquals(DocumentOpen.noHandlerMessage("text/plain"), plan.message)
+ }
+
+ // —— 来源选择 ——
+
+ @Test
+ fun `a committed reference comes from the server and is named by its storage name`() {
+ val target = DocumentTarget.of(ref(name = "0123456789abcdef0123456789abcdef.md"))
+
+ assertEquals(DocumentSource.Server("0123456789abcdef0123456789abcdef.md", "text/plain"), target.source)
+ // 原始文件名在契约里不可得,保存只能用存储名(不编一个假名字)。
+ assertEquals("0123456789abcdef0123456789abcdef.md", target.suggestedFileName())
+ }
+
+ @Test
+ fun `a draft attachment comes from the local uri and keeps its own name`() {
+ val target = DocumentTarget.of(DraftDocument(uri = "content://doc/1", displayName = "报告.md"))
+
+ assertEquals(DocumentSource.Local("content://doc/1", "报告.md"), target.source)
+ assertEquals("报告.md", target.suggestedFileName())
+ }
+
+ @Test
+ fun `a draft with no name falls back instead of an empty file name`() {
+ val target = DocumentTarget.of(DraftDocument(uri = "content://doc/1", displayName = ""))
+
+ assertEquals("document.txt", target.suggestedFileName())
+ }
+
+ // —— 失败文案:绝不渲染成「空文件」——
+
+ private fun server(code: String, message: String, status: Int) =
+ CyreneException(CyreneError.Server(code, message, status))
+
+ @Test
+ fun `a missing server document says the server no longer has it`() {
+ val message = documentLoadFailureMessage(server("document_not_found", "document not found", 404))
+
+ assertEquals("服务端上已经没有这份文件了", message)
+ }
+
+ @Test
+ fun `an expired session asks for a re-login rather than another file`() {
+ val message = documentLoadFailureMessage(CyreneException(CyreneError.Unauthorized("unauthorized", "x")))
+
+ assertTrue(message, message.contains("重新登录"))
+ }
+
+ @Test
+ fun `the three local failures each name the action the user can take`() {
+ assertEquals("读不到这份文件,请重新选择", documentLoadFailureMessage(LocalDocumentUnavailableException()))
+ assertTrue(documentLoadFailureMessage(LocalDocumentTooLargeException()).contains("20 MB"))
+ assertTrue(documentLoadFailureMessage(DocumentCacheUnavailableException()).contains("缓存"))
+ }
+
+ @Test
+ fun `a network failure keeps the network reason and names the step`() {
+ val message = documentLoadFailureMessage(
+ CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)),
+ )
+
+ assertEquals("附件加载失败,网络不可用,请检查连接", message)
+ }
+
+ @Test
+ fun `no failure ever reads like an empty file`() {
+ val messages = listOf(
+ documentLoadFailureMessage(server("document_not_found", "document not found", 404)),
+ documentLoadFailureMessage(server("weird_code", "boom", 500)),
+ documentLoadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout))),
+ documentLoadFailureMessage(LocalDocumentUnavailableException()),
+ documentLoadFailureMessage(LocalDocumentTooLargeException()),
+ documentLoadFailureMessage(DocumentCacheUnavailableException()),
+ documentLoadFailureMessage(IllegalStateException()),
+ )
+
+ assertTrue(messages.toString(), messages.all { it.isNotBlank() })
+ assertTrue(messages.toString(), messages.none { it.contains("空文件") || it.contains("是空的") })
+ }
+
+ /** 恰好 [count] 行(末行不带换行符)。 */
+ private fun lines(count: Int): String = buildString {
+ repeat(count - 1) { append("x\n") }
+ append('x')
+ }
+}