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

气泡里的图以前点了没有任何反应。现在点图进一个铺满全屏的弹层:fit-center 渲染、双指捏合
缩放(以双指中点为不动点)与单指拖动平移、双击在 1x 与 2x 之间切换(以双击点为不动点)。
位移与缩放全部按内容边界钳制——fit 态下拖不动(内容没超出容器)、放大后拖不到露出空白、
缩放恒在 1x..3x(下界比任务书的 0.5x 更紧:1x 就是「整张图刚好放得下」,再缩只是变小加留白)。
双击或关闭即复位(缩放状态活在弹层的组合里)。退出有右上角关闭按钮与系统返回键。

弹层底部两条出口与 W11 的附件预览同源,缓存与落盘复用 DocumentCache:图片也落在
cacheDir/documents/ 下,所以 FileProvider 的暴露面一个字节都没变(file_paths.xml 只改了注释,
没有新增暴露路径)。
- 用其它应用打开:声明图片自己的 mime(字节嗅探优先,其次平台声明的 image/ 前缀、名字扩展名,
  兜底 image 通配),经 FileProvider 交出去并带 FLAG_GRANT_READ_URI_PERMISSION;系统里没有
  应用能开时如实说一句并指路保存,绝不静默失败。
- 保存到下载目录:CreateDocument 一律传通配类型,否则 SAF 会按具体 mime 给文件名补扩展名
  (W11 实机抓到的 .md 变 .md.txt 那个坑;图片这边同样会变成 x.jpg.png)。

字节来源分三条,判定只发生在一处(ImageSource.of):服务端能力 URL(GET /api/v1/files/{name},
读侧免鉴权,所以用一条裸 OkHttp 客户端,而不是挂了 AuthInterceptor 的那条链——把 token 发给
一个免鉴权端点只多一个泄漏面)、内联 data: URI(字节就在字符串里,不联网)、本地 content 或
file 协议 URI(现读并拷进缓存,因此自己刚发的那张本地图也能交给别的 App)。

缓存名从服务端 URL 推,但要先过安全闸:单段、非 . / ..、不含分隔符与 NUL、不含百分号、扩展名
在图片白名单里;任何一条不满足就回落到按字节内容寻址——绝不拿一个不可信的名字拼路径。

加载态 / 失败态 / 就绪三者分明:取字节失败给一句话加一个重试入口;字节拿到了但解码不出来
(例如服务端被代理换成了 HTML 错误页)另给一句话,绝不画成一张空白图。重试把两条路都重来一遍。

测试 634 到 676(+42):来源判定、缓存名的安全闸、字节嗅探、打开与保存的类型选择、失败文案、
缩放平移的全部数学,以及图片缓存层的命中 / 回落 / 失败不留残留文件 / 真的走一次本地 HTTP
(JDK 自带的 HttpServer,验 200 / 404 / 0 字节三种响应)。纯装配部分(Dialog、手势识别、
Intent 投递、SAF 落盘)没有单测,留给实机验收。
This commit is contained in:
AskaEth committed 2026-10-04 19:48:18 +08:00
1 parent 8633ec9f06
commit 557ef3f524
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)))
}
}