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

@@ -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')
}
}