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>