merge: W11 — 附件芯片可点开预览 + 「用其它应用打开 / 保存到下载目录」两条出口(FileProvider)
This commit is contained in:
commit
60b0eacafd
9 files changed
+1625
-10
No files matched your search
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user