feat(chat): 附件芯片可点开预览,并给「用其它应用打开 / 保存到下载目录」两条出口

点一枚附件芯片此前完全没有反应。现在点它会开一个 App 内预览弹层,弹层里再给两条出口。

- FileProvider(authority = ${applicationId}.fileprovider,res/xml/file_paths.xml 只暴露
  cacheDir/documents/):把 file:// 交给别的 App 在 API 24+ 会抛 FileUriExposedException,
  必须走 content://;因此 cacheDir 下真正要交出去的那一层才被开出来。
- 新增 data/repository/DocumentCache.kt:缓存路径推导 / 命中判定 / 原子落盘是可单测的纯逻辑;
  DocumentCacheStore 把「服务端按存储名下载」与「本地待发附件」两条来源汇成同一份文件 + 字节。
  服务端存储名是内容寻址(sha256[:32]+ext)且读侧响应 immutable,所以命中即永不失效、命中时
  一个请求都不发;先写 .part 再改名、失败删干净——半截文件被下一次「命中」当成完整内容,
  是最坏的一种静默错误。
- 新增 domain/chat/DocumentPreview.kt(纯逻辑):截断(首 256 KB 或 5000 行,先到者为准;
  字节切点不切坏多字节 UTF-8)、正文分块(200 行一块)、来源判定(服务端 vs 本地,只有一处)、
  打开用的 MIME(只用 DTO 里字节嗅探所得的 mime,绝不按扩展名猜)、「没有 App 能开」的裁决与
  文案、取字节失败的分类文案。
- 新增 ui/chat/DocumentPreviewDialog.kt:Compose Dialog + Miuix 内容。正文用 LazyColumn 分块渲染
  (5000 行不塞进单个 Text),每块各自 SelectionContainer;加载态与失败态分明(失败给重试,
  绝不渲染成空文件);截断明写「只显示前 N,完整内容请用…」,不静默截断;复用 DocumentChips
  显示类型/体积/字数/状态,不另写一套标签;status 为 unreadable 不挡预览(那是模型读不到,
  文件本体一直在)。
- ui/chat/MessageBubble.kt:芯片整枚可点(点击区是那枚芯片本身,不是里面的两行文字),
  并带 contentDescription「查看附件 <name>」。
- ui/chat/ChatScreen.kt:预览状态 + 取字节 + SAF 保存 + ACTION_VIEW 打开。没有 App 能开时
  如实告知并指路保存,绝不静默失败。

契约缺口(需登记 02):服务端按内容寻址落盘,DTO 里没有原始文件名,保存到下载目录只能用
<32hex>.<ext>——弹层里已把这一点写在界面上,没有替用户编一个假名字。

测试 567 → 610(+29 DocumentPreviewTest / +14 DocumentCacheTest),0 失败 0 错误。
This commit is contained in:
AskaEth committed 2026-10-04 19:21:31 +08:00
1 parent e59ef08cf9
commit 27b6910009
9 files changed
+1625 -10

No files matched your search

@@ -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"
/** 缓存目录:`<cacheDir>/documents`。 */
fun directory(cacheDir: File): File = File(cacheDir, DIRECTORY_NAME)
/**
* 某个名字在缓存里的路径;名字**不安全**时回 `null`。
*
* 缓存目录是唯一被 FileProvider 暴露出去的根,所以这里对名字的容忍度取最小:单段、
* 非空、不是 `.` / `..`、不含分隔符与 NUL。服务端形态(`<32hex>.<ext>`)与
* [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<CachedDocument> = withContext(dispatcher) {
when (source) {
is DocumentSource.Server -> loadServer(source.name)
is DocumentSource.Local -> loadLocal(source)
}
}
private suspend fun loadServer(name: String): Result<CachedDocument> {
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<CachedDocument> {
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<CachedDocument> = try {
DocumentCache.write(file, bytes)
Result.success(CachedDocument(file, bytes))
} catch (_: Exception) {
// 写不进去时把可能的残留清掉,再如实报「缓存不可用」——预览本身也是靠缓存文件那条路
// 把完整内容交给别的 App 的,所以这里不回退成「只看得到一半」。
file.delete()
Result.failure(DocumentCacheUnavailableException())
}
}