merge: W14 — 点气泡里的图片进全屏查看,并接上「用其它应用打开 / 保存到下载目录」
This commit is contained in:
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)))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user