merge: W14 — 点气泡里的图片进全屏查看,并接上「用其它应用打开 / 保存到下载目录」

This commit is contained in:
AskaEth committed 2026-10-04 19:48:52 +08:00
commit f10984622c
10 files changed
+1790 -12

No files matched your search

@@ -4,10 +4,17 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiErrorMapper
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.ImageDataUri
import top.yeij.cyrene.domain.chat.ImageFormats
import top.yeij.cyrene.domain.chat.ImageNames
import top.yeij.cyrene.domain.chat.ImageSource
import top.yeij.cyrene.domain.chat.readBounded
import java.io.File
import java.io.IOException
@@ -26,6 +33,9 @@ class LocalDocumentTooLargeException : Exception("local document too large")
/** 缓存目录写不进去(连预览都做不了——字节拿到了也没地方交给别的 App)。 */
class DocumentCacheUnavailableException : Exception("document cache unavailable")
/** 服务端返回的图片字节超过本端一次性读取的上限(见 [ImageCacheStore] 那把尺子)。 */
class ImageTooLargeException : Exception("image too large")
/**
* `cacheDir/documents/` 的路径推导、命中判定与原子落盘(纯逻辑 + 文件操作,可单测)。
*
@@ -217,3 +227,154 @@ class DocumentCacheStore(
Result.failure(DocumentCacheUnavailableException())
}
}
/**
* 图片字节的取数与落盘(读侧是 `GET /api/v1/files/{name}`)。
*
* ## 为什么与附件挤在同一个缓存层
* 图片与附件落的是**同一个目录**(`cacheDir/documents/`),因此共用同一个 FileProvider 根。
* 「缓存目录是唯一被暴露出去的根」这条不变量只允许有一处实现:名字规则、命中判定、原子落盘
* 三条全部复用 [DocumentCache]——图片只是多用了「名字推不出来时按内容寻址」这一条。
* 不新开 `images/` 目录也不改 `file_paths.xml`:暴露面一个字节都没变。
*
* ## 三种来源各自的取字节方式
* - [ImageSource.Server]:先按 URL 里的名字看缓存(**命中一个请求都不发**,名字由内容决定
* ⇒ 命中永不失效),未命中才 GET 那个绝对地址;
* - [ImageSource.Inline]:小图内联进历史的那份 `data:` URI,字节就在字符串里,**不联网**;
* - [ImageSource.Local]:相册选择器的 `content://` / 分享落地的 `file://`,按 URI 现读。
*
* ## 为什么用一条**裸的** OkHttp 客户端
* 图片读侧是 128-bit 随机名构成的**能力 URL**,服务端刻意不校验 Bearer(`<img>` 也带不了头)。
* 所以这里不发 `Authorization`:把主人的 token 发到一个免鉴权端点上没有任何意义,只多一个泄漏面。
* ⚠️ 文档读侧(`GET /api/v1/documents/{name}`)**要** Bearer,那条路走 [DocumentCacheStore]。
*
* ## 为什么不复用 Coil 已经取过的那一份
* 气泡缩略图是 Coil 拉的,它的磁盘缓存**不给**「这一份普通文件在哪」这种契约(键与落盘格式都是
* 它内部的事)。而两条出口要交给系统别的 App 的恰恰是一份普通文件(FileProvider 只认路径)。
* 所以这里自己取一次、按名字落盘:代价是**首次打开一张服务端图会多下一次**(第二次起走本地
* 缓存),换来的是「预览看到的、打开给出的、保存写下的」是同一份字节,不会出现三条路各下一个。
*
* [download] 与 [openLocal] 都收成 lambda,把 OkHttp 与 `ContentResolver` 挡在类外:本类因此
* 可以在单测里用临时目录 + 假流跑完(`android.net.Uri` 在 JVM 单测里是 stub,一碰就抛)。
*/
class ImageCacheStore(
private val cacheDir: File,
private val openLocal: (String) -> InputStream?,
private val download: suspend (String) -> Result<ByteArray> = ::httpGetImage,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* 取回一张图的字节并确保它已落进缓存。
*
* 三种来源的产物是同一个形状([CachedDocument]):文件名 + 字节。这样「用其它应用打开 /
* 保存到下载目录」两条出口不必按来源分支——交给 FileProvider 的永远是缓存里那一份。
*/
suspend fun load(source: ImageSource): Result<CachedDocument> = withContext(dispatcher) {
when (source) {
is ImageSource.Server -> loadServer(source.raw)
is ImageSource.Inline -> loadInline(source.raw)
is ImageSource.Local -> loadLocal(source.raw)
}
}
private suspend fun loadServer(url: String): Result<CachedDocument> {
val name = ImageNames.fromServerUrl(url)
if (name != null) cachedHit(name)?.let { return it }
val bytes = download(url).getOrElse { return Result.failure(it) }
// 名字推不出来 / 拼不出路径(形态陌生、含可疑字符)时按**字节**内容寻址:
// 绝不拿一个不可信的名字去拼路径,也绝不因此拒绝打开这张图。
return write(name?.let { DocumentCache.fileFor(cacheDir, it) } ?: contentAddressed(bytes), bytes)
}
private fun loadInline(dataUri: String): Result<CachedDocument> {
val bytes = ImageDataUri.bytes(dataUri)
?: return Result.failure(LocalDocumentUnavailableException())
return write(contentAddressed(bytes), bytes)
}
private fun loadLocal(uri: String): Result<CachedDocument> {
val bytes = try {
val input = openLocal(uri)
?: return Result.failure(LocalDocumentUnavailableException())
input.use {
// 与附件同一把尺子:本端能一次读进内存的上限就是附件上限。
readBounded(it, DocumentAttachments.MAX_FILE_BYTES)
} ?: return Result.failure(LocalDocumentTooLargeException())
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
return Result.failure(LocalDocumentUnavailableException())
}
return write(contentAddressed(bytes), bytes)
}
/** 缓存命中:名字可信、文件在且非空、且真的读得出来。读不动就删掉重来,绝不当成空图返回。 */
private fun cachedHit(name: String): Result<CachedDocument>? {
val file = DocumentCache.fileFor(cacheDir, name) ?: return null
if (!DocumentCache.isHit(file)) return null
val bytes = runCatching { file.readBytes() }.getOrNull() ?: run {
file.delete()
return null
}
return Result.success(CachedDocument(file, bytes))
}
/** 内容寻址的缓存名(与服务端文档同一规则):`sha256(bytes)[:32] + 嗅探所得的扩展名`。 */
private fun contentAddressed(bytes: ByteArray): File? =
DocumentCache.fileFor(
cacheDir,
DocumentCache.localName(bytes, CONTENT_ADDRESSED_HINT + ImageFormats.extensionFor(ImageFormats.sniff(bytes))),
)
private fun write(file: File?, bytes: ByteArray): Result<CachedDocument> {
val target = file
?: return Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
// 内容寻址的名字相同 ⇒ 字节相同:已经落过盘就不重写(同一张图重复打开不该反复写盘)。
if (DocumentCache.isHit(target)) return Result.success(CachedDocument(target, bytes))
return try {
DocumentCache.write(target, bytes)
Result.success(CachedDocument(target, bytes))
} catch (_: Exception) {
target.delete()
Result.failure(DocumentCacheUnavailableException())
}
}
private companion object {
/** 内容寻址名的扩展名载体:`localName` 只看扩展名,名字本身会被 sha256 换掉。 */
const val CONTENT_ADDRESSED_HINT = "image"
}
}
/** 只服务免鉴权图片读侧的裸客户端;进程内一份,连接池与线程池复用。 */
private val imageHttpClient: OkHttpClient by lazy { OkHttpClient() }
/**
* GET 一个图片地址并读回字节(至多 [DocumentAttachments.MAX_FILE_BYTES])。
*
* 非 2xx 走 [ApiErrorMapper]——与所有 REST 仓库同一口径:服务端有话说就用它那句,没有就说不出
* 的时候也不编。2xx 却读不出字节(0 字节 / 超过上限)同样判失败:**绝不**把一份空字节落进缓存,
* 那会让下一次打开「命中」一份什么都没有的文件。
*/
private suspend fun httpGetImage(url: String): Result<ByteArray> = withContext(Dispatchers.IO) {
try {
imageHttpClient.newCall(Request.Builder().url(url).get().build()).execute().use { response ->
if (!response.isSuccessful) {
return@withContext Result.failure(
CyreneException(ApiErrorMapper.fromHttp(response.code, response.body.string())),
)
}
val bytes = readBounded(response.body.byteStream(), DocumentAttachments.MAX_FILE_BYTES)
when {
bytes == null -> Result.failure(ImageTooLargeException())
bytes.isEmpty() -> Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
else -> Result.success(bytes)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
}
}