From 27b6910009af9eda40a7f9d09e88eaa11914c76e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 19:21:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E9=99=84=E4=BB=B6=E8=8A=AF?= =?UTF-8?q?=E7=89=87=E5=8F=AF=E7=82=B9=E5=BC=80=E9=A2=84=E8=A7=88=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E7=BB=99=E3=80=8C=E7=94=A8=E5=85=B6=E5=AE=83=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E6=89=93=E5=BC=80=20/=20=E4=BF=9D=E5=AD=98=E5=88=B0?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E7=9B=AE=E5=BD=95=E3=80=8D=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E5=87=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 点一枚附件芯片此前完全没有反应。现在点它会开一个 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「查看附件 」。 - ui/chat/ChatScreen.kt:预览状态 + 取字节 + SAF 保存 + ACTION_VIEW 打开。没有 App 能开时 如实告知并指路保存,绝不静默失败。 契约缺口(需登记 02):服务端按内容寻址落盘,DTO 里没有原始文件名,保存到下载目录只能用 <32hex>.——弹层里已把这一点写在界面上,没有替用户编一个假名字。 测试 567 → 610(+29 DocumentPreviewTest / +14 DocumentCacheTest),0 失败 0 错误。 --- app/src/main/AndroidManifest.xml | 17 + .../cyrene/data/repository/DocumentCache.kt | 219 +++++++++++ .../cyrene/domain/chat/DocumentPreview.kt | 367 ++++++++++++++++++ .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 186 ++++++++- .../cyrene/ui/chat/DocumentPreviewDialog.kt | 220 +++++++++++ .../top/yeij/cyrene/ui/chat/MessageBubble.kt | 42 +- app/src/main/res/xml/file_paths.xml | 14 + .../data/repository/DocumentCacheTest.kt | 260 +++++++++++++ .../cyrene/domain/chat/DocumentPreviewTest.kt | 310 +++++++++++++++ 9 files changed, 1625 insertions(+), 10 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/repository/DocumentCache.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentPreview.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/chat/DocumentPreviewDialog.kt create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/repository/DocumentCacheTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentPreviewTest.kt 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') + } +}