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

This commit is contained in:
AskaEth committed 2026-10-04 19:22:25 +08:00
commit 60b0eacafd
9 files changed
+1625 -10

No files matched your search

+17
View File
@@ -48,6 +48,23 @@
</intent-filter>
</activity>
<!--
附件预览的「用其它应用打开 / 保存」出口:`cacheDir/documents/` 下的文件要交给
系统里别的 App 读,就必须是 content:// 而不是 file://——API 24 起把 file:// 交出去
会直接抛 FileUriExposedException(且 StrictMode 下同样致命)。
路径范围见 res/xml/file_paths.xml:只暴露 cacheDir/documents/ 这一层,
exported="false" + 按次授权(grantUriPermissions)保证这份缓存不对外常开。
-->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -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())
}
}
@@ -0,0 +1,367 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.data.api.dto.DocumentRefDto
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
import top.yeij.cyrene.data.repository.LocalDocumentTooLargeException
import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException
import java.util.Locale
/**
* 预览 / 打开 / 保存要处理的那一份附件**从哪来**。
*
* 判定只发生在一处([DocumentTarget.of]),预览取字节、交给别的 App、保存到下载目录三条路
* 都从这里取来源——散成两处就会出现「预览读的是服务端、打开读的是本地」这种半截行为。
*
* 两种来源是**真的事实差异**,不是实现细节:已落库的附件只有服务端存储名(消息 DTO 刻意不带
* 正文,见契约 v0.59 §1.2 ④),还没发出去的待发附件**只有**本地 URI(那时还没有存储名)。
*/
sealed interface DocumentSource {
/**
* 已落库消息里的附件:字节要按服务端存储名现拉(`GET /api/v1/documents/{name}`,要 Bearer)。
*
* [mime] 是 DTO 里那个**字节嗅探**所得的基类型——「用其它应用打开」就用它,
* 不从 [name] 的扩展名猜(见 [DocumentOpenTypes.forServer])。
*/
data class Server(val name: String, val mime: String) : DocumentSource
/** 待发附件:字节在本地 URI 上(SAF 的 `content://` 或分享落地后的 `file://`)。 */
data class Local(val uri: String, val displayName: String) : DocumentSource
}
/**
* 被点开的那一枚附件芯片(纯数据,UI 照着它渲染预览弹层)。
*
* [chips] 就是气泡里那枚芯片的模型([DocumentChips]),**刻意复用**:弹层再写一套
* 「类型 · 体积 · 字数 · 状态」的标签,两边迟早会有一处漏改状态语义。
*/
data class DocumentTarget(
val source: DocumentSource,
val chips: DocumentChipModel,
) {
/**
* 「用其它应用打开」要声明的类型。
*
* 服务端来源用 DTO 里嗅探所得的 [DocumentSource.Server.mime];本地来源只能来自平台声明
* (`ContentResolver.getType`,由调用方传进来)。**两条路都不从扩展名猜**:服务端的
* `data.json` 嗅探回的是 `text/plain`,按扩展名猜成 `application/json` 等于替服务端
* 说一个它刻意不承认的类型。
*/
fun openType(declaredLocalType: String? = null): String = when (val origin = source) {
is DocumentSource.Server -> DocumentOpenTypes.forServer(origin.mime)
is DocumentSource.Local -> DocumentOpenTypes.forLocal(declaredLocalType)
}
/**
* 保存到下载目录时建议的文件名。
*
* ⚠️ **服务端来源只能用存储名**:落盘名是 `sha256(bytes)[:32] + ext`(内容寻址),
* 原始文件名**永不参与路径**、也不回客户端——DTO 里根本没有它。所以这里如实给
* `<32hex>.<ext>`,**绝不**替用户编一个「用户当初叫它什么」的假名字(那会是本端凭空的
* 信息)。本地待发附件倒是还有用户当初选的那份显示名。
*/
fun suggestedFileName(): String = when (val origin = source) {
is DocumentSource.Server -> origin.name
is DocumentSource.Local -> origin.displayName.ifBlank { FALLBACK_FILE_NAME }
}
companion object {
/** 已落库消息的附件引用 → 目标(来源恒为服务端)。 */
fun of(ref: DocumentRefDto): DocumentTarget =
DocumentTarget(DocumentSource.Server(ref.name, ref.mime), DocumentChips.of(ref))
/** 待发附件 → 目标(来源恒为本地)。 */
fun of(draft: DraftDocument): DocumentTarget =
DocumentTarget(DocumentSource.Local(draft.uri, draft.displayName), DocumentChips.of(draft))
}
}
private const val FALLBACK_FILE_NAME = "document.txt"
/**
* 「用其它应用打开」时 Intent 上要声明的 MIME。
*
* 与 `ChatScreen` 选文件时那份 [DocumentPickerMimeTypes] 相反:那是「让用户选得到」的宽口径,
* 这里是「交给别的 App 时声明它是什么」——**只有服务端嗅探(或平台声明)这一个真源**,
* 判不出来宁可用 `application/octet-stream`,也不按扩展名猜。
*/
object DocumentOpenTypes {
const val OCTET_STREAM: String = "application/octet-stream"
/** 服务端文档:DTO 的 `mime` 是嗅探所得基类型(唯一裁决);服务端嗅不出类型时也下发 `octet-stream`。 */
fun forServer(mime: String): String = mime.trim().ifBlank { OCTET_STREAM }
/** 本地待发文件:类型只可能来自平台声明;拿不到就回 `octet-stream`(不猜扩展名)。 */
fun forLocal(declared: String?): String = declared?.trim()?.takeIf { it.isNotEmpty() } ?: OCTET_STREAM
}
/** 「用其它应用打开」这一步的裁决结果。 */
sealed interface DocumentOpenPlan {
/** 系统里有 App 能处理这个类型:把 Intent 交出去。 */
data object HandOff : DocumentOpenPlan
/**
* 没有任何 App 能打开这个类型:**必须如实告知**,并指路「保存到下载目录」。
*
* 静默什么也不发生是最坏的结果——用户按了按钮,界面纹丝不动,他既不知道失败在哪,
* 也不知道还有别的出路。这条分支因此带一句现成的话,而不是一个光秃秃的布尔。
*/
data class NoHandler(val message: String) : DocumentOpenPlan
}
/** 「用其它应用打开」的裁决(纯函数:`PackageManager` 只回答 [plan] 的 `hasHandler` 那一问)。 */
object DocumentOpen {
/**
* @param type 交给 Intent 的 MIME([DocumentOpenTypes] 的产物)。
* @param hasHandler 系统里是否有 App 能处理 `ACTION_VIEW + type`(UI 层查 `PackageManager`)。
*/
fun plan(type: String, hasHandler: Boolean): DocumentOpenPlan =
if (hasHandler) DocumentOpenPlan.HandOff else DocumentOpenPlan.NoHandler(noHandlerMessage(type))
/**
* 没有任何 App 能处理 [type] 时的那句话。
*
* 单独暴露一个入口是因为「解析得到 handler」与「真的启动起来」之间还有一段窗口(App 被
* 卸载 / 被停用)——那条 `ActivityNotFoundException` 分支要说的是**同一句话**,不能在那里
* 另写一份文案(两份迟早会分叉)。
*/
fun noHandlerMessage(type: String): String =
"没有应用能打开「$type」格式的文件,可以「保存到下载目录」后再处理"
}
/** 预览的截断阈值(两个上限,**先到者为准**)。 */
object DocumentPreviewLimits {
/**
* 单次预览最多取前 256 KB 字节。
*
* 取值理由:附件上限是 20 MiB(`CHAT_DOCUMENT_MAX_BYTES`),一份 20 MiB 的文本全塞进单个
* `Text` 会让测量 / 布局在打开弹层那一刻卡住。256 KB 足够看清开头、翻不到底的都在下一条
* 出口(「用其它应用打开」/「保存」)——那两条给的是**完整**内容。
*/
const val MAX_BYTES: Int = 256 * 1024
/** 行数上限:与字节上限先到者为准(19 万行的短行文件在 256 KB 内也能有 10 万行)。 */
const val MAX_LINES: Int = 5_000
/**
* 正文分块的粒度(行)。
*
* [MAX_LINES] 5000 行如果是一个 `Text`,一次测量就要算整段排版;切成 200 行一块交给
* `LazyColumn`,只测当前可见的几块(最多 25 块)。
*/
const val LINES_PER_CHUNK: Int = 200
}
/** 预览为什么只显示了一段([None] = 完整显示)。 */
enum class PreviewTruncation {
None,
/** 撞上 [DocumentPreviewLimits.MAX_BYTES]。 */
Bytes,
/** 撞上 [DocumentPreviewLimits.MAX_LINES]。 */
Lines,
}
/**
* 预览正文的一段切片。
*
* [shownBytes] / [shownLines] 是**实际显示了多少**,[totalBytes] / [totalLines] 是全文多少。
* 截断说明([notice])里报的是**阈值本身**(256 KB / 5,000 行):实际显示量最多比阈值少 3 字节
* (UTF-8 回退那一刀),把它精确写进一句话只会得到「只显示前 262,143 字节」这种没人想看的东西。
*/
data class PreviewSlice(
val text: String,
val truncatedBy: PreviewTruncation,
val shownBytes: Int,
val shownLines: Int,
val totalBytes: Int,
val totalLines: Int,
) {
val truncated: Boolean get() = truncatedBy != PreviewTruncation.None
/**
* 截断时那句**必须明写**的话;完整显示时回 `null`(一个字都不说)。
*
* 静默截断是这一块的红线:用户看到一段像是「文件就这么长」的正文,会得出错误结论,
* 而真相(后面还有)**只有服务端知道**——所以这里既说「只显示前 N」,也指路两个能给
* 完整内容的出口。
*/
val notice: String?
get() {
val shown = when (truncatedBy) {
PreviewTruncation.None -> return null
PreviewTruncation.Bytes -> "${DocumentPreviewLimits.MAX_BYTES / 1024} KB"
PreviewTruncation.Lines -> "${grouped(DocumentPreviewLimits.MAX_LINES)} 行"
}
return "只显示前 $shown,完整内容请用「用其它应用打开」或「保存到下载目录」查看"
}
}
/** 文档正文的取段与分块(纯函数,UI 与测试共用)。 */
object DocumentPreview {
/**
* 取前 [DocumentPreviewLimits.MAX_BYTES] 字节 / [DocumentPreviewLimits.MAX_LINES] 行,
* 先到者为准。
*
* 顺序是「先字节、后行数」:字节上限决定能解码出多长的文本,行数上限在这一段里再切一次。
* 于是两者都超时,[PreviewSlice.truncatedBy] 报 [PreviewTruncation.Bytes]——那才是真正
* 先切断它的那一刀。
*/
fun slice(bytes: ByteArray): PreviewSlice {
val totalBytes = bytes.size
val totalLines = lineCount(bytes)
val byteLimited = totalBytes > DocumentPreviewLimits.MAX_BYTES
val prefix = if (byteLimited) {
utf8SafePrefixLength(bytes, DocumentPreviewLimits.MAX_BYTES)
} else {
totalBytes
}
var text = String(bytes, 0, prefix, Charsets.UTF_8)
val lineLimited = lineCount(text) > DocumentPreviewLimits.MAX_LINES
if (lineLimited) text = takeLines(text, DocumentPreviewLimits.MAX_LINES)
return PreviewSlice(
text = text,
truncatedBy = when {
byteLimited -> PreviewTruncation.Bytes
lineLimited -> PreviewTruncation.Lines
else -> PreviewTruncation.None
},
shownBytes = text.toByteArray(Charsets.UTF_8).size,
shownLines = lineCount(text),
totalBytes = totalBytes,
totalLines = totalLines,
)
}
/**
* 按行数分块([DocumentPreviewLimits.LINES_PER_CHUNK] 行一块)。
*
* 每块**保留自己的换行符**,因此把各块顺序拼回来等于原文——`LazyColumn` 逐块渲染时行距
* 不会因为切点而错位。空文本回空列表(UI 不必为它画一个空块)。
*/
fun chunks(text: String, linesPerChunk: Int = DocumentPreviewLimits.LINES_PER_CHUNK): List<String> {
if (text.isEmpty()) return emptyList()
if (linesPerChunk <= 0) return listOf(text)
val chunks = mutableListOf<String>()
var start = 0
var lines = 0
for (index in text.indices) {
if (text[index] != '\n') continue
lines++
if (lines == linesPerChunk) {
chunks += text.substring(start, index + 1)
start = index + 1
lines = 0
}
}
if (start < text.length) chunks += text.substring(start)
return chunks
}
/** 文本行数:`\n` 计数 + 末尾那行(末尾换行符不算额外一行);空文本是 0 行。 */
fun lineCount(text: String): Int = when {
text.isEmpty() -> 0
text.endsWith('\n') -> text.count { it == '\n' }
else -> text.count { it == '\n' } + 1
}
/**
* 字节层面的行数——**不解码整份文件**就能算出行数,因此 [slice] 报「共多少行」时
* 不必把 20 MiB 全文变成一个 `String`。
*
* 直接数 `0x0A`:UTF-8 的续字节(`0x80..0xBF`)与各多字节序列里都不可能出现 `0x0A`,
* 所以字节计数与字符计数一致。
*/
fun lineCount(bytes: ByteArray, length: Int = bytes.size): Int {
if (length <= 0) return 0
var newlines = 0
for (index in 0 until length) {
if ((bytes[index].toInt() and 0xFF) == NEWLINE) newlines++
}
return if ((bytes[length - 1].toInt() and 0xFF) == NEWLINE) newlines else newlines + 1
}
/**
* 在 [limit] 处取前截断时,把末尾一个**被切成两半的多字节 UTF-8 字符**退回。
*
* 不退的后果是正文末尾多出一个 `U+FFFD`(`String(bytes, UTF_8)` 的替换字符)——看着像
* 文件本身就是坏的,而实际上是本端自己切坏的。退回最多 3 字节即可(UTF-8 最长 4 字节)。
*
* @return 可以安全解码的字节数(≤ [limit])。输入本身不合法(整段都是续字节)时原样返回
* [limit]——不替一个坏文件找补。
*/
fun utf8SafePrefixLength(bytes: ByteArray, limit: Int): Int {
if (limit >= bytes.size) return bytes.size
if (limit <= 0) return 0
var lead = limit
var continuation = 0
while (
lead > 0 &&
continuation < UTF8_MAX_BYTES - 1 &&
(bytes[lead - 1].toInt() and 0xC0) == UTF8_CONTINUATION
) {
lead--
continuation++
}
if (lead == 0) return limit
val expected = when (bytes[lead - 1].toInt() and 0xFF) {
in 0x00..0x7F -> 1
in 0xC0..0xDF -> 2
in 0xE0..0xEF -> 3
in 0xF0..0xF7 -> 4
else -> 1
}
val available = limit - lead + 1
return if (available < expected) lead - 1 else limit
}
/** 取前 [count] 行(含第 [count] 行的换行符);不足 [count] 行时原样返回。 */
private fun takeLines(text: String, count: Int): String {
if (count <= 0) return ""
var seen = 0
for (index in text.indices) {
if (text[index] != '\n') continue
seen++
if (seen == count) return text.substring(0, index + 1)
}
return text
}
private const val NEWLINE = 0x0A
private const val UTF8_MAX_BYTES = 4
private const val UTF8_CONTINUATION = 0x80
}
/**
* 预览取字节失败时的文案(纯函数,可单测)。
*
* 与 [top.yeij.cyrene.ui.chat.documentUploadFailureMessage] 同一纪律:服务端能说清的就用它
* 的原话(含真实原因),本地能分类的就分类说,**绝不把失败渲染成「空文件」**。
*
* 三条本地失败必须分开,因为它们要用户做的事不一样:读不到 = 重新选一份;太大 = 换一份;
* 缓存写不进去 = 这是本端的问题,重试多半还是同样的结果。
*/
fun documentLoadFailureMessage(e: Throwable): String = when (e) {
is LocalDocumentUnavailableException -> "读不到这份文件,请重新选择"
is LocalDocumentTooLargeException -> "这份文件超过 ${DocumentAttachments.maxFileSizeLabel},无法预览"
is DocumentCacheUnavailableException -> "本地缓存不可用,暂时无法预览这份附件"
else -> when (val failure = e.toDocumentFailure()) {
// 服务端对「名字非法」与「文件不存在」刻意同码(不泄露差异)——本端只说能确定的那半句。
is DocumentFailure.NotFound -> "服务端上已经没有这份文件了"
is DocumentFailure.Other -> "附件加载失败," + failure.message
else -> failure.message
}
}
/** 千分位分组(只用于行数这类计数,纯展示)。 */
private fun grouped(value: Int): String = String.format(Locale.ROOT, "%,d", value)
@@ -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<DocumentTarget?>(null) }
// 「用其它应用打开 / 保存」这一步的如实告知(没有 App 能开、保存成功或失败),进弹层显示。
var previewNotice by remember { mutableStateOf<DocumentPreviewNotice?>(null) }
var previewReload by remember { mutableStateOf(0) }
val previewState by produceState<DocumentPreviewState>(
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<DraftDocument>, onRemove: (Int) -> Unit) {
private fun DraftDocumentChips(
documents: List<DraftDocument>,
onRemove: (Int) -> Unit,
onClick: (Int) -> Unit,
) {
DocumentChipRow(
chips = documents.map(DocumentChips::of),
onRemove = onRemove,
onClick = onClick,
modifier = Modifier.padding(bottom = 6.dp),
)
}
@@ -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
@@ -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,
) {
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
FileProvider 的暴露范围(对应 AndroidManifest 里那个 provider)。
只开 cacheDir/documents/ 一层:附件预览把服务端文档与待发文件都落在那里
(见 data/repository/DocumentCache.kt),交给别的 App 读的就是这一份。
刻意不写 <cache-path path="." /> 或 <files-path>——暴露整个 cacheDir 等于把
应用的全部临时文件都摆到一个可被 grantUriPermission 的根下。
-->
<paths>
<cache-path
name="documents"
path="documents/" />
</paths>
@@ -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<String, ByteArray> = 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<ApiEnvelope<DocumentUploadResponse>> = error("上传不在本测试的范围")
override suspend fun download(name: String): Response<ResponseBody> {
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"
}
}
@@ -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<String>(), 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')
}
}