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