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)))
}
}
@@ -0,0 +1,269 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.repository.DocumentCache
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
import top.yeij.cyrene.data.repository.ImageTooLargeException
import top.yeij.cyrene.data.repository.LocalDocumentTooLargeException
import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException
import java.util.Base64
import java.util.Locale
/**
* 被点开的这张图**从哪来**。
*
* 判定只发生在一处([of]),渲染、取字节、交给别的 App 三条路都从这里取来源——散成两处就会
* 出现「预览看的是这张、打开给的却是另一张」这种半截行为。
*
* 三种来源是**真的事实差异**,不是实现细节:
* - [Server]:上传产物的绝对地址(`POST /api/v1/upload` 的 `url`)。读侧 `GET /api/v1/files/{name}`
* 是**免鉴权**的 128-bit 能力 URL,所以这条路的字节可以直接 GET(与文档读侧刻意相反)。
* ⚠️ 已知缺口(`02/F6①`):`url` 由服务端按上传时的 `r.Host` 拼成绝对地址并原样落库
* ⇒ **换服务器地址后旧图取不回**。本模块不修它,但也不掩盖:取不到就如实报错。
* - [Inline]:小图内联进历史的那份 `data:` URI([ChatImageDelivery] 的 Inline 档)。字节就在
* 这个字符串里,**不联网**。
* - [Local]:本地引用——相册选择器的 `content://`、分享落地的 `file://`。字节要现读。
*/
sealed interface ImageSource {
/**
* 交给渲染与取字节用的**原始**字符串:气泡里 `message.image` 的那一份,或待发图片的本地 URI。
*
* 刻意不在这里归一化成「某种标准地址」:渲染交给 Coil(它自己认 `data:` / `content://` /
* `http(s)`),取字节由 [ImageCacheStore] 按来源分支。多一层转换只会多一处漂移的余地。
*/
val raw: String
/** 上传产物的绝对地址(`http(s)://<host>/api/v1/files/<name>`);读侧免鉴权。 */
data class Server(override val raw: String) : ImageSource
/** 内联进历史的 `data:` URI(`data:image/png;base64,…`)。 */
data class Inline(override val raw: String) : ImageSource
/** 本地引用:`content://`(相册)/ `file://`(分享落地)都走它;读不到会如实报错。 */
data class Local(override val raw: String) : ImageSource
companion object {
/**
* 分类(纯字符串判断,不做任何 IO)。
*
* 空串回 `null`——那种图本来也不会被渲染。**其余一律落到 [Local]**:一个既不是
* `data:` 也不是 `http(s)://` 的字符串只可能是本地引用(`content://` / `file://` /
* 裸路径),按本地读、读不到就报「读不到这张图片」。这样做的理由是**不许静默什么都不
* 发生**:形态陌生时点一下没反应,比给出一句说得清的失败糟得多。
*/
fun of(raw: String): ImageSource? = when {
raw.isBlank() -> null
raw.startsWith("data:", ignoreCase = true) -> Inline(raw)
raw.startsWith("http://", ignoreCase = true) -> Server(raw)
raw.startsWith("https://", ignoreCase = true) -> Server(raw)
else -> Local(raw)
}
}
}
/**
* 图片的三种形态互推:**字节 ↔ mime ↔ 扩展名**。
*
* 放行的集合与服务端 `POST /api/v1/upload` 的白名单**同源**(png / jpeg / gif / webp / bmp):
* 那正是服务端会接受、并且会给它起 `<32hex>.<ext>` 名字的一批。刻意**不**把 HEIC / AVIF 列进来
* ——它们不在服务端白名单里,本端也拿不到可比对的裁决;但本地相册里的 HEIC 照样能看(渲染交给
* Coil,解码是系统的事),只是本端说不出它的精确类型,那时「用其它应用打开」用的是**平台声明**
* 的类型(见 [ImageOpenTypes.openType])。
*/
object ImageFormats {
const val MIME_PNG: String = "image/png"
const val MIME_JPEG: String = "image/jpeg"
const val MIME_GIF: String = "image/gif"
const val MIME_WEBP: String = "image/webp"
const val MIME_BMP: String = "image/bmp"
/**
* 字节嗅探 → mime;认不出回 `null`。
*
* 只看文件头,不解码整张图:这一层要能在取字节之后立刻给答案,而不必把位图读进内存。
* 判据与服务端 upload 的白名单同一套(服务端用 `http.DetectContentType`,本端按同样的签名
* 手判)——认不出就说认不出,**不猜**(图片侧猜错的代价是让一个错的类型进 Intent)。
*/
fun sniff(bytes: ByteArray): String? = when {
bytes.startsWith(PNG_MAGIC) -> MIME_PNG
bytes.startsWith(JPEG_MAGIC) -> MIME_JPEG
bytes.startsWith(GIF_MAGIC) -> MIME_GIF
bytes.isWebp() -> MIME_WEBP
bytes.isBmp() -> MIME_BMP
else -> null
}
/** 名字/地址里的扩展名 → mime;不在图片白名单里回 `null`(不猜)。 */
fun mimeForExtension(name: String?): String? =
when (name?.substringAfterLast('.', "")?.lowercase(Locale.ROOT).orEmpty()) {
"png" -> MIME_PNG
"jpg", "jpeg" -> MIME_JPEG
"gif" -> MIME_GIF
"webp" -> MIME_WEBP
"bmp" -> MIME_BMP
else -> null
}
/** mime → 带点的扩展名(内容寻址的缓存名要用);认不出回空串。 */
fun extensionFor(mime: String?): String = when (mime) {
MIME_PNG -> ".png"
MIME_JPEG -> ".jpg"
MIME_GIF -> ".gif"
MIME_WEBP -> ".webp"
MIME_BMP -> ".bmp"
else -> ""
}
private fun ByteArray.startsWith(magic: ByteArray): Boolean =
size >= magic.size && magic.indices.all { this[it] == magic[it] }
/** WEBP 是 RIFF 容器:前 4 字节 `RIFF`、第 8…11 字节 `WEBP`,只看开头是不够的。 */
private fun ByteArray.isWebp(): Boolean =
size >= WEBP_MARKER_OFFSET + WEBP_MARKER.size &&
startsWith(RIFF_MAGIC) &&
WEBP_MARKER.indices.all { this[WEBP_MARKER_OFFSET + it] == WEBP_MARKER[it] }
/** BMP 是 `BM` 开头的固定 14 字节文件头;只看那两字节太容易撞上以「BM」开头的文本。 */
private fun ByteArray.isBmp(): Boolean = size >= BMP_HEADER_BYTES && startsWith(BMP_MAGIC)
private val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
private val JPEG_MAGIC = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte())
private val GIF_MAGIC = "GIF8".toByteArray(Charsets.US_ASCII)
private val RIFF_MAGIC = "RIFF".toByteArray(Charsets.US_ASCII)
private val WEBP_MARKER = "WEBP".toByteArray(Charsets.US_ASCII)
private val BMP_MAGIC = "BM".toByteArray(Charsets.US_ASCII)
private const val WEBP_MARKER_OFFSET = 8
private const val BMP_HEADER_BYTES = 14
}
/**
* 服务端图片地址 → 缓存名。
*
* ## 为什么可以拿 URL 的最后一段当文件名
* `POST /api/v1/upload` 的产物名是服务端生成的 `<32hex>.<ext>`,落进 `<dataDir>/uploads/`,
* 读侧只认「32 位小写 hex + 白名单扩展名」。同一个名字的字节**永不改变**(上传产物没有覆盖
* 写入口),所以**名字就是缓存键、命中即永不失效**——与文档那条内容寻址的结论一致。
*
* ## 为什么容忍度取最小
* 这个名字会变成 `cacheDir/documents/` 下的一个文件名,而那个目录正是 FileProvider 唯一暴露出去
* 的根(见 [DocumentCache.isSafeName])。所以只接受「最后一段、单段、非空、不是 `.` / `..`、
* 不含分隔符与 NUL、不含 `%`、扩展名在图片白名单里」的名字;任何一条不满足就回 `null`,
* 由调用方**回落到按字节内容寻址**——宁可多落一份文件,也绝不用一个不可信的名字拼路径。
*
* 对 `%` 格外苛刻是有意的:本端**从不**解释 URL 编码,所以一个带 `%` 的段说明它压根不是我们
* 认识的那个服务端名字(服务端的读侧也明确拒绝 `%2f` / `%2e%2e` 这些形态)。
*/
object ImageNames {
/** 从图片地址里推出缓存名;推不出来 / 不可信时回 `null`。 */
fun fromServerUrl(url: String): String? {
// 先切掉 fragment 与查询串:缓存名只认路径那一段(`?v=2` 这种不该进文件名)。
val path = url.substringBefore('#').substringBefore('?')
val name = path.substringAfterLast('/')
if (name.isBlank() || name == "." || name == "..") return null
if (name.any { it == '\\' || it == '\u0000' || it == '%' }) return null
if (!DocumentCache.isSafeName(name)) return null
return name.takeIf { ImageFormats.mimeForExtension(it) != null }
}
}
/**
* 内联图片(`data:image/png;base64,…`)的解析。
*
* 用 JDK 的 `java.util.Base64` 而**不是** `android.util.Base64`:这一层是纯逻辑,要能在 JVM
* 单测里直接跑——`android.util` 在单测里是 stub,一碰就抛。
*/
object ImageDataUri {
/** 头部声明的 mime(`data:image/png;base64,…` → `image/png`);没有就回 `null`。 */
fun declaredMime(dataUri: String): String? = dataUri
.substringAfter("data:", "")
.substringBefore(',')
.substringBefore(';')
.trim()
.takeIf { it.isNotEmpty() }
/**
* 解出内联的字节;不是 base64 的 `data:` URI / 解不出来 / 解出来是空的都回 `null`。
*
* 用 MIME 解码器(忽略行内换行等非字母表字符)而不是严格解码器:`data:` URI 里的 base64
* 允许折行,严格解码器会把一整张能看的图判成坏数据。代价是「一整串垃圾」也可能解出一段垃圾
* 字节——那一段走到渲染那一步会解不开,由调用方如实报「解不开」,**不会**画成一张空白图。
*/
fun bytes(dataUri: String): ByteArray? {
if (!dataUri.startsWith("data:", ignoreCase = true)) return null
val comma = dataUri.indexOf(',')
if (comma < 0) return null
val head = dataUri.substring("data:".length, comma)
if (!head.contains(";base64", ignoreCase = true)) return null
return runCatching {
Base64.getMimeDecoder().decode(dataUri.substring(comma + 1))
}.getOrNull()?.takeIf { it.isNotEmpty() }
}
}
/**
* 「用其它应用打开」时 Intent 上要声明的 MIME,以及「保存到下载目录」时给 SAF 的类型。
*
* 与文档那条路同一纪律,但**两条路的类型选择刻意不同**(W11 实机抓到的真 bug):
* - **打开**要的正是「能读懂这段内容的类型」⇒ 尽可能精确(嗅探 > 平台声明 > 扩展名 > 图片通配);
* - **保存**恒为「通配」类型([ImageOpenTypes.SAVE_MIME])⇒ `CreateDocument` 传具体 mime 时,
* SAF 的 Downloads provider 会按它给文件名**补扩展名**(2026-10-05 实测:存的 `…e.md` 变成
* `…e.md.txt`)。图片这边同样危险:服务端名字的扩展名与**字节嗅探**所得的基类型不保证一致,
* 补一次就成了 `x.jpg.png`。
*/
object ImageOpenTypes {
/** 说不清是什么图时的兜底:让图片类应用都能接(不是 `application/octet-stream`)。 */
const val ANY_IMAGE: String = "image/*"
/** 保存到下载目录时给 SAF 的类型:**恒为通配**(为什么不能用具体 mime 见本对象的 KDoc)。 */
const val SAVE_MIME: String = "*/*"
private const val IMAGE_PREFIX = "image/"
/**
* 交给别的 App 时声明的类型,四级回落。
*
* 顺序是**证据强度**的顺序,不是「谁先拿到」的顺序:
* 1. [sniffed] —— 字节嗅探。服务端对图片也是这个口径(`DetectContentType` 是唯一裁决),
* 本端拿到的是同一份字节,所以它比任何名字和声明都硬;
* 2. [declared] —— 平台声明(本地来源的 `ContentResolver.getType` / 内联 `data:` URI 的头)。
* **只认 `image/` 开头的那种**:有些 provider 对图片报 `application/octet-stream`,
* 拿它去开图等于告诉对方「这是一坨字节」,不如退到扩展名;
* 3. [name] 的扩展名 —— 服务端名字里的扩展名来自它自己那一次嗅探,是可信的第二手证据;
* 4. [ANY_IMAGE]。
*/
fun openType(sniffed: String?, declared: String?, name: String?): String {
sniffed?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
declared?.trim()?.takeIf { it.startsWith(IMAGE_PREFIX) }?.let { return it }
ImageFormats.mimeForExtension(name)?.let { return it }
return ANY_IMAGE
}
}
/**
* 取这张图的字节失败时的文案(纯函数,可单测)。
*
* 与 [documentLoadFailureMessage] 同一纪律:服务端能说清的就用它的原话,本地能分类的就分类说,
* **绝不把失败渲染成一张空白图**。三条本地失败分开写,因为它们要用户做的事不一样:读不到 =
* 再点一次或重新发一张;太大 = 这个本端处理不了;缓存写不进去 = 本端的问题,重试多半还是这样。
*
* 三个异常类型与文档共用(它们描述的是**缓存层**的三种处境,不是文档专属);两边的文案各自成句。
*/
fun imageLoadFailureMessage(e: Throwable): String = when (e) {
is LocalDocumentUnavailableException -> "读不到这张图片"
is LocalDocumentTooLargeException -> "这张图片超过 ${DocumentAttachments.maxFileSizeLabel},无法预览"
is ImageTooLargeException -> "这张图片超过 ${DocumentAttachments.maxFileSizeLabel},无法预览"
is DocumentCacheUnavailableException -> "本地缓存不可用,暂时无法预览这张图片"
is CyreneException -> when (e.toDocumentFailure()) {
// 服务端对「名字非法」与「文件不存在」刻意同码(不泄露差异)——本端只说能确定的那半句。
is DocumentFailure.NotFound -> "服务端上已经没有这张图片了"
else -> "图片加载失败," + e.error.userMessage
}
else -> "图片加载失败,请稍后重试"
}
@@ -0,0 +1,162 @@
package top.yeij.cyrene.domain.chat
/**
* 一个矩形尺寸(像素)。
*
* 刻意**不用** Compose 的 `Size` / `IntSize`:这一层是纯手势数学,要能在 JVM 单测里直接跑,
* 也不想让 domain 依赖 UI 框架。UI 那边把 `IntSize` 换算成它,只有两行。
*/
data class ImageSize(val width: Float, val height: Float)
/** 全屏查看的缩放 / 平移:喂给 `graphicsLayer` 的三个数。 */
data class ZoomPan(
val scale: Float = ImageZoom.MIN_SCALE,
val offsetX: Float = 0f,
val offsetY: Float = 0f,
)
/**
* 全屏查看的缩放 / 平移数学(纯函数,UI 只负责把触摸事件喂进来、把结果画出去)。
*
* ## 坐标约定
* `offsetX` / `offsetY` 是**相对容器中心**的位移;[ImageSize] 给出两个尺寸:
* - `container`:手势区的大小;
* - `image`:图片的原始像素尺寸(只要宽高比正确即可,缩略图尺寸也行)。
*
* 于是「scale = 1」就是 **fit-center**:[fittedSize] 算出的内容尺寸(等比、贴住容器的一边)。
* 任何偏移都在 [clampOffset] 里被钳到「内容超出容器的那一半」以内——内容比容器小的那一轴恒为 0,
* 因此 fit 态下**根本拖不动**,图片永远不可能被拖到看不见的地方。
*
* ## 为什么下界是 1x 而不是任务书里那句「不低于 0.5x」
* 1x 就是「整张图刚好放得下」。再往下缩只会让图比屏幕还小、四周留白,没有任何能看的东西,
* 所以这里取一个**更紧**的下界(0.5x 满足「不低于 0.5x」,但它是个不需要的状态)。
* 上界 3x:比这更大就只能看几个像素,而双指继续外扩还有系统的手势冲突风险。
*/
object ImageZoom {
/** 下界 = fit-center(见 KDoc)。 */
const val MIN_SCALE: Float = 1f
/** 上界。 */
const val MAX_SCALE: Float = 3f
/** 双击放到的倍数(双击是 1x ↔ 2x 的开关,不是「放大一点」)。 */
const val DOUBLE_TAP_SCALE: Float = 2f
/** 判定「还在 fit 态」的容差:`clampScale` 的产物就是字面量 1f,但浮点乘法会留下尾巴。 */
private const val EPSILON = 0.01f
/** 缩放钳制:非有限值(NaN / Infinity,来自 `zoom` 因子的极端输入)一律回 [MIN_SCALE]。 */
fun clampScale(scale: Float): Float =
if (scale.isFinite()) scale.coerceIn(MIN_SCALE, MAX_SCALE) else MIN_SCALE
/** 是否处在放大态(双击据此决定「放大到 2x」还是「收回 1x」)。 */
fun isZoomed(scale: Float): Boolean = scale > MIN_SCALE + EPSILON
/** 双击的目标倍数:fit 态 → 2x;已放大 → 回 fit。 */
fun doubleTapScale(current: Float): Float =
if (isZoomed(current)) MIN_SCALE else DOUBLE_TAP_SCALE
/**
* fit-center 之后、scale = 1 时的内容尺寸(等比缩到贴住容器的一边)。
*
* 任一侧非正(还没测量出来 / 图片尺寸未知)时回 `(0, 0)`:调用方据此跳过钳制,
* 而不是拿 `NaN` 去算位移。
*/
fun fittedSize(container: ImageSize, image: ImageSize): ImageSize {
if (container.width <= 0f || container.height <= 0f) return ImageSize(0f, 0f)
if (image.width <= 0f || image.height <= 0f) return ImageSize(0f, 0f)
val fit = minOf(container.width / image.width, container.height / image.height)
return ImageSize(image.width * fit, image.height * fit)
}
/**
* 单轴的位移上限:内容放大后**超出容器的那一半**(内容比容器小时为 0)。
*
* 取一半是因为偏移以容器中心为原点:往右最多能移「超出量 / 2」,再移就会露出空白。
*/
fun maxOffset(containerSide: Float, contentSide: Float, scale: Float): Float {
if (!containerSide.isFinite() || !contentSide.isFinite() || !scale.isFinite()) return 0f
return ((contentSide * scale - containerSide) / 2f).coerceAtLeast(0f)
}
/** 单轴钳制(越界即贴边;fit 态下恒为 0,所以拖不动)。 */
fun clampOffset(offset: Float, containerSide: Float, contentSide: Float, scale: Float): Float {
if (!offset.isFinite()) return 0f
val limit = maxOffset(containerSide, contentSide, scale)
return offset.coerceIn(-limit, limit)
}
/** 拖动平移:先加上手指位移,再按两轴各自钳制。 */
fun pan(
current: ZoomPan,
deltaX: Float,
deltaY: Float,
container: ImageSize,
image: ImageSize,
): ZoomPan = clamped(
scale = current.scale,
offsetX = current.offsetX + deltaX,
offsetY = current.offsetY + deltaY,
container = container,
image = image,
)
/**
* 以 [focusX] / [focusY](**相对容器中心**的坐标)为不动点缩放到 [targetScale]。
*
* 推导:屏幕上被手指按住的那个点在内容坐标里是 `c = (focus - offset) / scale`;缩放后要让它
* 落在同一处,于是 `offset' = focus - c * scale'`,即 `offset' = focus - (focus - offset) * (scale'/scale)`。
* 缩回 fit 时这个式子算出来的位移会被 [clampOffset] 归零(内容不再超出容器),正是要的结果。
*/
fun zoomAround(
current: ZoomPan,
targetScale: Float,
focusX: Float,
focusY: Float,
container: ImageSize,
image: ImageSize,
): ZoomPan {
val scale = clampScale(targetScale)
val base = if (current.scale.isFinite() && current.scale > 0f) current.scale else MIN_SCALE
val ratio = scale / base
return clamped(
scale = scale,
offsetX = focusX - (focusX - current.offsetX) * ratio,
offsetY = focusY - (focusY - current.offsetY) * ratio,
container = container,
image = image,
)
}
/** 双击:以双击点为不动点在 fit 与 [DOUBLE_TAP_SCALE] 之间切换(回 fit 时位移自然归零)。 */
fun doubleTap(
current: ZoomPan,
focusX: Float,
focusY: Float,
container: ImageSize,
image: ImageSize,
): ZoomPan = zoomAround(
current = current,
targetScale = doubleTapScale(current.scale),
focusX = focusX,
focusY = focusY,
container = container,
image = image,
)
private fun clamped(
scale: Float,
offsetX: Float,
offsetY: Float,
container: ImageSize,
image: ImageSize,
): ZoomPan {
val fitted = fittedSize(container, image)
return ZoomPan(
scale = scale,
offsetX = clampOffset(offsetX, container.width, fitted.width, scale),
offsetY = clampOffset(offsetY, container.height, fitted.height, scale),
)
}
}
@@ -59,6 +59,7 @@ 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.repository.ImageCacheStore
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DocumentOpen
@@ -68,6 +69,7 @@ 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.ImageSource
import top.yeij.cyrene.domain.chat.documentLoadFailureMessage
import top.yeij.cyrene.domain.share.ShareRules
import top.yeij.cyrene.domain.share.SharedContent
@@ -232,6 +234,21 @@ fun ChatScreen(
}
}
// —— 图片全屏查看 ——
// 与附件同一层缓存(图片也落在 cacheDir/documents/ 下,FileProvider 暴露的就是这一层,
// 不额外扩暴露面);取字节分三条路(服务端 URL / 内联 data: / 本地 URI),见 ImageCacheStore。
val imageCache = remember(container) {
ImageCacheStore(
cacheDir = container.appContext.cacheDir,
openLocal = { uri ->
runCatching {
container.appContext.contentResolver.openInputStream(Uri.parse(uri))
}.getOrNull()
},
)
}
var imageTarget by remember { mutableStateOf<ImageSource?>(null) }
// 系统相册选择器(PickVisualMedia 走 Photo Picker,**不需要任何存储权限**)。
// 只把 URI 交给 VM:真正的解码 / 缩放 / base64 在发送时于 IO 线程做。
val imagePicker = rememberLauncherForActivityResult(
@@ -357,6 +374,11 @@ fun ChatScreen(
previewNotice = null
previewTarget = DocumentTarget.of(ref)
},
// 来源判定在 domain 里(ImageSource.of),这里只把弹层打开——
// 「点一张图」这件事不该在接线层判断它是什么地址。
onImageClick = { raw ->
ImageSource.of(raw)?.let { imageTarget = it }
},
)
is ChatDisplayItem.Pending -> PendingBubble(
@@ -365,6 +387,9 @@ fun ChatScreen(
previewNotice = null
previewTarget = DocumentTarget.of(draft)
},
onImageClick = { raw ->
ImageSource.of(raw)?.let { imageTarget = it }
},
)
ChatDisplayItem.Streaming -> state.streaming?.let { StreamingBubble(it) }
@@ -421,6 +446,14 @@ fun ChatScreen(
},
)
}
imageTarget?.let { source ->
ImagePreviewDialog(
source = source,
store = imageCache,
onDismiss = { imageTarget = null },
)
}
}
/**
@@ -0,0 +1,465 @@
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.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.FileProvider
import coil3.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yeij.cyrene.data.repository.CachedDocument
import top.yeij.cyrene.data.repository.ImageCacheStore
import top.yeij.cyrene.domain.chat.DocumentOpen
import top.yeij.cyrene.domain.chat.DocumentOpenPlan
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.ImageOpenTypes
import top.yeij.cyrene.domain.chat.ImageSize
import top.yeij.cyrene.domain.chat.ImageSource
import top.yeij.cyrene.domain.chat.ImageZoom
import top.yeij.cyrene.domain.chat.ZoomPan
import top.yeij.cyrene.domain.chat.imageLoadFailureMessage
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.theme.MiuixTheme
import java.io.File
/**
* 全屏查看的三种状态。**加载中 / 失败 / 就绪必须长得不一样**:
* 取字节失败要能重试,而且**绝不能被画成一张空白图**——「按了没反应」和「图是白的」是两种
* 都会被误读成「这张图就是这样」的失败。
*/
sealed interface ImagePreviewState {
data object Loading : ImagePreviewState
/** 取字节失败;[message] 是 [imageLoadFailureMessage] 的产物。 */
data class Failed(val message: String) : ImagePreviewState
/**
* 字节已落进缓存。[cached] 同时是渲染、交给别的 App、写进下载目录的那**同一份**;
* [openType] 是「用其它应用打开」要声明的 MIME(见 [ImageOpenTypes.openType])。
*/
data class Ready(val cached: CachedDocument, val openType: String) : ImagePreviewState
}
/**
* 图片全屏查看弹层。
*
* 与附件预览([DocumentPreviewDialog])是同一套做法的另一份:`Dialog` + Miuix 内容、
* 缓存与落盘复用 [DocumentCache],只是这一份要**铺满全屏**(`usePlatformDefaultWidth = false`)
* 才谈得上 fit-center 与放大。
*
* ## 手势
* - **双指捏合**缩放([ImageZoom.zoomAround],以双指中点为不动点)、**单指拖动**平移;
* - **双击**在 1x ↔ 2x 之间切换(以双击点为不动点;收回 fit 时位移自动归零);
* - 位移与缩放全部经 [ImageZoom] 钳制:fit 态下拖不动(内容没超出容器),放大后拖不到
* 露出空白,缩放范围恒在 1x…3x 之间。
*
* ## 两个出口(与 W11 完全相同的那两条)
* - **用其它应用打开**:把缓存里那一份经 **FileProvider** 交出去,类型用**图片自己的 mime**
* (嗅探 > 平台声明 > 扩展名 > 图片通配);系统里没有 App 能开时**如实说一句**,
* 并指路「保存到下载目录」——绝不静默失败。
* - **保存到下载目录**:走 `ACTION_CREATE_DOCUMENT`,类型**恒为通配**([ImageOpenTypes.SAVE_MIME])
* ——否则 SAF 会按具体 mime 给文件名补扩展名。
*
* ## 退出
* 右上角关闭按钮 + 系统返回键([BackHandler];`Dialog` 自己也会把返回交给 `onDismissRequest`,
* 两者通向同一个「只关弹层、什么都不写」)。缩放状态活在弹层的组合里,**关闭即复位**,
* 再打开永远从 fit 开始。
*
* @param source 被点开的那张图(来源判定见 [ImageSource.of],弹层不自己判断)。
* @param store 取字节 + 落盘的缓存层(与附件共用 `cacheDir/documents/` 那一层)。
*/
@Composable
fun ImagePreviewDialog(
source: ImageSource,
store: ImageCacheStore,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// 重试计数:取字节失败与解码失败共用它,一次「重试」把两条路都重来一遍。
var retry by remember(source) { mutableStateOf(0) }
// 「用其它应用打开 / 保存」这一步的如实告知(没有 App 能开、保存成功或失败)。
// 复用附件弹层那个类型:它就是「弹层下方的一行告知」,与文档无关。
var notice by remember(source) { mutableStateOf<DocumentPreviewNotice?>(null) }
// Coil 解不开这份字节(拿到了、但不是它能认的图):与「取字节失败」分开说——两件事
// 要用户做的事不一样,前者可以重试,后者换了网络也没用。
var undecodable by remember(source, retry) { mutableStateOf(false) }
val state by produceState<ImagePreviewState>(
initialValue = ImagePreviewState.Loading,
key1 = source,
key2 = retry,
) {
value = ImagePreviewState.Loading
value = store.load(source).fold(
onSuccess = { cached ->
ImagePreviewState.Ready(cached, imageOpenType(context, source, cached))
},
onFailure = { ImagePreviewState.Failed(imageLoadFailureMessage(it)) },
)
}
// 保存到下载目录:与附件那条路同一取舍——MIME 恒 */*,落盘的是缓存里那一份**原始字节**
// (不是重新编码过的位图,JPEG 存下来还是 JPEG)。
val createDocument = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument(ImageOpenTypes.SAVE_MIME),
) { destination ->
val document = (state as? ImagePreviewState.Ready)?.cached
if (destination == null || document == null) return@rememberLauncherForActivityResult
scope.launch {
val saved = withContext(Dispatchers.IO) {
runCatching {
context.contentResolver.openOutputStream(destination)?.use {
it.write(document.bytes)
} ?: error("no output stream for $destination")
}.isSuccess
}
notice = if (saved) {
DocumentPreviewNotice("已保存")
} else {
DocumentPreviewNotice("保存失败,请换一个位置再试", error = true)
}
}
}
fun openElsewhere() {
val ready = state as? ImagePreviewState.Ready ?: return
val contentUri = runCatching {
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", ready.cached.file)
}.getOrNull()
if (contentUri == null) {
notice = DocumentPreviewNotice("无法把这张图交给其它应用:本地缓存文件不存在", error = true)
return
}
val intent = Intent(Intent.ACTION_VIEW)
.setDataAndType(contentUri, ready.openType)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val hasHandler = context.packageManager
.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null
// 裁决与文案复用附件那条路([DocumentOpen]):语义完全一样——「交给系统里能读这个类型的
// 应用」,没有能处理的就如实说一句并指路保存。
when (val plan = DocumentOpen.plan(ready.openType, hasHandler)) {
DocumentOpenPlan.HandOff -> try {
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
notice = DocumentPreviewNotice(DocumentOpen.noHandlerMessage(ready.openType), error = true)
}
is DocumentOpenPlan.NoHandler -> notice = DocumentPreviewNotice(plan.message, error = true)
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
// 系统返回键:只关弹层,绝不落到聊天页的返回层级上([ChatHomeScreen] 那几层返回不变)。
BackHandler(enabled = true) { onDismiss() }
Box(
modifier = Modifier
.fillMaxSize()
.background(MiuixTheme.colorScheme.surface),
) {
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center,
) {
when (val current = state) {
ImagePreviewState.Loading -> CenteredHint("正在加载图片…")
is ImagePreviewState.Failed -> FailurePane(
message = current.message,
onRetry = { retry++ },
)
is ImagePreviewState.Ready -> if (undecodable) {
FailurePane(
message = "这张图片解不开(内容可能不是图片)",
onRetry = { retry++ },
)
} else {
ZoomableImage(
file = current.cached.file,
onUndecodable = { undecodable = true },
)
}
}
}
ExitPane(
ready = state as? ImagePreviewState.Ready,
notice = notice,
note = nameNoteFor(source),
onOpen = { openElsewhere() },
onSave = {
val name = (state as? ImagePreviewState.Ready)?.cached?.file?.name
createDocument.launch(name ?: FALLBACK_IMAGE_NAME)
},
)
}
// 右上角关闭:底部那一列出口只讲「拿这张图去做什么」,退出这件事单独放在它的位置。
IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp),
) {
Icon(
imageVector = MiuixIcons.Basic.Close,
contentDescription = "关闭",
tint = MiuixTheme.colorScheme.onSurface,
)
}
}
}
}
/**
* 可缩放平移的图片。
*
* 渲染的**是缓存里那一份文件**(不是原始 URL):取字节与落盘已经在 [ImageCacheStore] 里做完,
* 于是「看得到的」与「交给别的 App 的」是同一条路上的同一份字节——不会出现「预览能看、打开
* 却失败」。Coil 只负责解码本地文件(HEIC 这类本端说不清类型的图在这里也能看,解码是系统的事)。
*
* 两个 `pointerInput` 分开挂:一个认双击,一个认捏合与拖动。它们不会互相吃掉——
* 拖动一旦过了 touch slop 就被 `detectTransformGestures` 消费,双击因此不会被拖动误触。
* 键取两个尺寸:测量结果到达时重建手势检测器,避免闭包里留着上一次的尺寸。
*
* @param onUndecodable 字节拿到了但 Coil 解不开(例如服务端被代理换成了 HTML 错误页)。
*/
@Composable
private fun ZoomableImage(file: File, onUndecodable: () -> Unit) {
var containerSize by remember { mutableStateOf(IntSize.Zero) }
var imageSize by remember { mutableStateOf(IntSize.Zero) }
val zoom = remember { mutableStateOf(ZoomPan()) }
val container = ImageSize(containerSize.width.toFloat(), containerSize.height.toFloat())
val image = ImageSize(imageSize.width.toFloat(), imageSize.height.toFloat())
Box(
modifier = Modifier
.fillMaxSize()
.clipToBounds()
.onSizeChanged { containerSize = it }
.pointerInput(imageSize, containerSize) {
detectTransformGestures { centroid, pan, zoomChange, _ ->
val focusX = centroid.x - containerSize.width / 2f
val focusY = centroid.y - containerSize.height / 2f
val zoomed = ImageZoom.zoomAround(
current = zoom.value,
targetScale = zoom.value.scale * zoomChange,
focusX = focusX,
focusY = focusY,
container = container,
image = image,
)
zoom.value = ImageZoom.pan(zoomed, pan.x, pan.y, container, image)
}
}
.pointerInput(imageSize, containerSize) {
detectTapGestures(
onDoubleTap = { position ->
zoom.value = ImageZoom.doubleTap(
current = zoom.value,
focusX = position.x - containerSize.width / 2f,
focusY = position.y - containerSize.height / 2f,
container = container,
image = image,
)
},
)
},
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = file,
contentDescription = "查看的图片",
contentScale = ContentScale.Fit,
onSuccess = { result ->
imageSize = IntSize(result.result.image.width, result.result.image.height)
},
onError = { onUndecodable() },
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = zoom.value.scale
scaleY = zoom.value.scale
translationX = zoom.value.offsetX
translationY = zoom.value.offsetY
},
)
}
}
/**
* 底部那一列出口(与附件弹层同一形态)。
*
* 字节还没到位([ImagePreviewState.Loading] / [ImagePreviewState.Failed])时两条出口**不给点**:
* 点了也只能失败,不如让它们明摆着还没准备好。但**关闭**任何时候都在(右上角那个按钮)。
*/
@Composable
private fun ExitPane(
ready: ImagePreviewState.Ready?,
notice: DocumentPreviewNotice?,
note: String,
onOpen: () -> Unit,
onSave: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp, bottom = 12.dp),
) {
notice?.let { line ->
Text(
text = line.message,
style = MiuixTheme.textStyles.footnote2,
color = if (line.error) {
MiuixTheme.colorScheme.error
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
modifier = Modifier.padding(horizontal = EXIT_TEXT_MARGIN, vertical = 6.dp),
)
}
PreferenceCard {
BasicComponent(
title = "用其它应用打开",
summary = ready?.let { "交给系统里能看 ${it.openType} 的应用" } ?: "图片还在加载",
onClick = if (ready == null) null else onOpen,
)
}
Spacer(Modifier.height(8.dp))
PreferenceCard {
BasicComponent(
title = "保存到下载目录",
summary = "自己挑一个落点,内容与这里看到的是同一份",
onClick = if (ready == null) null else onSave,
)
}
Spacer(Modifier.height(8.dp))
Text(
text = note,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(horizontal = EXIT_TEXT_MARGIN, vertical = 2.dp),
)
}
}
@Composable
private fun CenteredHint(text: String) {
Text(
text = text,
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
/** 取字节失败 / 解不开:一句话说清 + 一个重试出口(**绝不画成一张空白图**)。 */
@Composable
private fun FailurePane(message: String, onRetry: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = message,
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 24.dp),
)
Spacer(Modifier.height(10.dp))
TextButton("重试", onClick = onRetry)
}
}
/**
* 「用其它应用打开」要声明的类型。
*
* 服务端来源在地址里就有名字(那扩展名来自服务端自己那一次嗅探);本地来源要问一次平台
* (`ContentResolver.getType`,一次 IPC,只在打开时做,不做缓存);内联来源的类型写在
* `data:` 头里。三者都只是**候选**,最终由 [ImageFormats.sniff](字节)压过它们——
* 字节是唯一裁决,与文档那条路同一纪律。
*/
private fun imageOpenType(context: Context, source: ImageSource, cached: CachedDocument): String {
val declared = when (source) {
is ImageSource.Local -> runCatching {
context.contentResolver.getType(Uri.parse(source.raw))
}.getOrNull()
is ImageSource.Inline -> ImageDataUri.declaredMime(source.raw)
is ImageSource.Server -> null
}
val name = when (source) {
is ImageSource.Server -> ImageNames.fromServerUrl(source.raw)
// 内联与本地都没有服务端名字,用缓存里那个(内容寻址 + 嗅探所得的扩展名)。
else -> cached.file.name
}
return ImageOpenTypes.openType(ImageFormats.sniff(cached.bytes), declared, name)
}
/** 名字这件事按来源如实说:服务端来源的名字是它的存储名,还没上去的那张是本端自己算的。 */
private fun nameNoteFor(source: ImageSource): String = when (source) {
is ImageSource.Server ->
"服务端只保留了内容寻址的存储名,保存下来的文件名就是它,原始文件名不会返回给客户端。"
else ->
"这张图还没有服务端存储名,保存时用的是本端按内容(sha256)算出来的名字。"
}
/** 建议文件名的兜底:理论上到不了(缓存名恒非空),只是不让一个空名字进 SAF。 */
private const val FALLBACK_IMAGE_NAME = "image.png"
/** 底部告知文字与左侧卡片的对齐余量([PreferenceCard] 自己带 12dp 外边距)。 */
private val EXIT_TEXT_MARGIN = 20.dp
@@ -51,13 +51,17 @@ private const val CURSOR = "▍"
* - 其它 mode 原样整条渲染,不解析标签;
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
* - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画);
* - `image` 非空时在气泡上方显示图片。
* - `image` 非空时在气泡上方显示图片,**点它进全屏查看**([onImageClick])。
*
* 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer],
* 因此选择菜单的「全选」只作用于当前那一块,而不是整个页面。
*/
@Composable
fun MessageBubble(message: HistoryMessageDto, onDocumentClick: (DocumentRefDto) -> Unit = {}) {
fun MessageBubble(
message: HistoryMessageDto,
onDocumentClick: (DocumentRefDto) -> Unit = {},
onImageClick: (String) -> Unit = {},
) {
val isUser = message.role == "user"
// 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同:
// 表只能给「id → 标签」,说不出哪个 mode 是「按 markup 切气泡的聊天式渲染」,
@@ -75,7 +79,7 @@ fun MessageBubble(message: HistoryMessageDto, onDocumentClick: (DocumentRefDto)
alignEnd = isUser,
onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) },
)
message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it, onImageClick) }
bubbles.forEachIndexed { index, bubble ->
Bubble(bubble, isUser, isLast = index == bubbles.lastIndex)
}
@@ -88,16 +92,21 @@ fun MessageBubble(message: HistoryMessageDto, onDocumentClick: (DocumentRefDto)
* 图片显示的是**本地** `content://` URI(不是上行的那段 base64):预览不需要联网、
* 也不必把几 MB 的字符串塞进 UI 状态。落库后由 `message.image` 接管。
* 附件同理显示**本地显示名**(那时还没有服务端存储名,也还没有任何服务端结论)。
* 这张本地图同样可点进全屏查看([onImageClick]):读的是本地那一份字节。
*/
@Composable
fun PendingBubble(item: PendingMessage, onDocumentClick: (DraftDocument) -> Unit = {}) {
fun PendingBubble(
item: PendingMessage,
onDocumentClick: (DraftDocument) -> Unit = {},
onImageClick: (String) -> Unit = {},
) {
MessageGroup(isUser = true, footer = "发送中…") {
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) }
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it, onImageClick) }
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
}
}
@@ -317,14 +326,24 @@ private fun BubbleBody(text: String, isUser: Boolean) {
}
}
/**
* 气泡里的图片(缩略)。
*
* 点了就进全屏查看——[onClick] 收到的是**原始**那一份字符串(`data:` / `http(s)` / `content://`),
* 来源判定交给 `ImageSource.of`,这里不猜。点击区就是这张图本身(`clip` 之后可点,
* 涟漪不会溢出圆角);读屏也需要知道点它会发生什么,所以带上 `contentDescription`。
*
* 高度不设限:图片按比例撑开,气泡那一列本来就是纵向排列。
*/
@Composable
private fun ChatImage(model: String) {
private fun ChatImage(model: String, onClick: (String) -> Unit) {
AsyncImage(
model = model,
contentDescription = null,
contentDescription = "查看图片",
modifier = Modifier
.widthIn(max = 240.dp)
.clip(RoundedCornerShape(14.dp)),
.clip(RoundedCornerShape(14.dp))
.clickable { onClick(model) },
)
}
+4 -4
View File
@@ -2,10 +2,10 @@
<!--
FileProvider 的暴露范围(对应 AndroidManifest 里那个 provider)。
只开 cacheDir/documents/ 一层:附件预览把服务端文档与待发文件都落在那里
(见 data/repository/DocumentCache.kt),交给别的 App 读的就是这一份。
刻意不写 <cache-path path="." /> 或 <files-path>——暴露整个 cacheDir 等于把
应用的全部临时文件都摆到一个可被 grantUriPermission 的根下。
只开 cacheDir/documents/ 一层:附件预览把服务端文档与待发文件都落在那里,
图片全屏查看也把取回来的字节落进同一层(见 data/repository/DocumentCache.kt),
交给别的 App 读的就是这一份。刻意不写 <cache-path path="." /> 或 <files-path>——
暴露整个 cacheDir 等于把应用的全部临时文件都摆到一个可被 grantUriPermission 的根下。
-->
<paths>
<cache-path
@@ -0,0 +1,254 @@
package top.yeij.cyrene.data.repository
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import kotlinx.coroutines.runBlocking
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 top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.domain.chat.ImageSource
import java.io.File
import java.net.InetSocketAddress
import java.nio.file.Files
import java.util.Base64
/**
* 图片取字节 + 落盘这条路的可测部分:三条来源各自的取法、缓存命中(不重复联网)、
* 不可信名字的回落、失败不留半截文件,以及**真的走一次 HTTP**(用 JDK 自带的
* `com.sun.net.httpserver` 起一个本地端点,验 200 / 404 / 0 字节三种响应)。
*
* 用真临时目录而不是 Android 的 `Context`:这一层的行为全是文件系统与 HTTP 行为,
* 只有把它从 `ContentResolver`(JVM 单测里是 stub,一碰就抛)里摘出来才测得到。
*/
class ImageCacheTest {
private lateinit var cacheRoot: File
@Before
fun setUp() {
cacheRoot = Files.createTempDirectory("cyrene-images").toFile()
}
@After
fun tearDown() {
cacheRoot.deleteRecursively()
}
// —— 服务端来源:命名、命中、回落 ——
@Test
fun `a server image is downloaded once and then served from cache`() = runBlocking {
val downloads = mutableListOf<String>()
val store = store(download = { url -> downloads += url; Result.success(pngBytes()) })
val first = store.load(ImageSource.Server(SERVER_URL)).getOrThrow()
val second = store.load(ImageSource.Server(SERVER_URL)).getOrThrow()
assertEquals(NAME, first.file.name)
assertEquals("同一个存储名的字节永不改变 ⇒ 第二次一个请求都不发", 1, downloads.size)
assertEquals(first.file, second.file)
assertEquals(pngBytes().size.toLong(), second.file.length())
}
@Test
fun `an untrustworthy url name never becomes a path and the image still opens`() = runBlocking {
val downloads = mutableListOf<String>()
val store = store(download = { url -> downloads += url; Result.success(pngBytes()) })
// 编码过的穿越形态:ImageNames 推不出可信名字 ⇒ 按内容寻址落盘(既不该拼路径,也不该拒开)。
val loaded = store.load(ImageSource.Server("http://host/files/%2e%2e%2fsecret.png")).getOrThrow()
assertEquals(1, downloads.size)
assertEquals(32 + 4, loaded.file.name.length)
assertTrue(loaded.file.name.endsWith(".png"))
assertTrue("落盘必须在缓存目录里", loaded.file.parentFile == DocumentCache.directory(cacheRoot))
assertEquals(listOf(loaded.file.name), DocumentCache.directory(cacheRoot).list()!!.toList())
}
@Test
fun `a failed download leaves no half file for the next open to believe`() = runBlocking {
val store = store(
download = { Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork))) },
)
val result = store.load(ImageSource.Server(SERVER_URL))
assertTrue(result.isFailure)
assertFalse(DocumentCache.fileFor(cacheRoot, NAME)!!.exists())
assertFalse("下载失败连缓存目录都不该建", DocumentCache.directory(cacheRoot).exists())
}
@Test
fun `a zero byte leftover is treated as a miss and refetched`() = runBlocking {
val leftover = DocumentCache.fileFor(cacheRoot, NAME)!!
leftover.parentFile!!.mkdirs()
leftover.writeBytes(ByteArray(0))
var downloads = 0
val store = store(download = { downloads++; Result.success(pngBytes()) })
val loaded = store.load(ImageSource.Server(SERVER_URL)).getOrThrow()
assertEquals(1, downloads)
assertEquals(pngBytes().size.toLong(), loaded.file.length())
}
@Test
fun `a cache directory that cannot be written is reported as such`() = runBlocking {
// 拿一个**普通文件**当缓存目录:建目录必然失败。
val blocked = File(cacheRoot, "blocked").apply { writeBytes("not a directory".toByteArray()) }
val store = ImageCacheStore(
cacheDir = blocked,
openLocal = { null },
download = { Result.success(pngBytes()) },
)
val result = store.load(ImageSource.Server(SERVER_URL))
assertTrue(result.exceptionOrNull() is DocumentCacheUnavailableException)
}
// —— 内联来源:不联网 ——
@Test
fun `an inline data uri is decoded locally and never requested`() = runBlocking {
var downloads = 0
val store = store(download = { downloads++; error("内联图片不该发请求") })
val dataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(pngBytes())
val loaded = store.load(ImageSource.Inline(dataUri)).getOrThrow()
assertEquals(0, downloads)
assertEquals(pngBytes().size.toLong(), loaded.file.length())
assertEquals(DocumentCache.localName(pngBytes(), "image.png"), loaded.file.name)
}
@Test
fun `a broken data uri is reported instead of being cached as an empty image`() = runBlocking {
val store = store(download = { error("不该发请求") })
val result = store.load(ImageSource.Inline("data:image/png;base64,%%%%"))
assertTrue(result.exceptionOrNull() is LocalDocumentUnavailableException)
assertFalse(DocumentCache.directory(cacheRoot).exists())
}
// —— 本地来源:拷进缓存 ——
@Test
fun `a local image is copied in content addressed and never requested`() = runBlocking {
val store = store(
download = { error("本地图片不该发请求") },
local = mapOf(LOCAL_URI to pngBytes()),
)
val loaded = store.load(ImageSource.Local(LOCAL_URI)).getOrThrow()
assertEquals(DocumentCache.localName(pngBytes(), "image.png"), loaded.file.name)
assertEquals(pngBytes().size.toLong(), loaded.file.length())
}
@Test
fun `a local image we cannot open is reported as unreadable`() = runBlocking {
val store = store(download = { error("不该走到网络") }, local = emptyMap())
val result = store.load(ImageSource.Local(LOCAL_URI))
assertTrue(result.exceptionOrNull() is LocalDocumentUnavailableException)
}
// —— 真的走一次 HTTP(默认的 download 实现)——
@Test
fun `the default download reads a real response and maps the failures`() {
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/files/$NAME") { exchange -> respond(exchange, 200, pngBytes()) }
server.createContext("/files/empty.png") { exchange -> respond(exchange, 200, ByteArray(0)) }
server.createContext("/files/gone.png") { exchange ->
respond(
exchange,
404,
"""{"error":{"code":"not_found","message":"no such file"}}""".toByteArray(),
)
}
server.start()
try {
val base = "http://127.0.0.1:${server.address.port}/files"
val store = ImageCacheStore(cacheDir = cacheRoot, openLocal = { null })
val loaded = runBlocking { store.load(ImageSource.Server("$base/$NAME")) }.getOrThrow()
assertEquals(NAME, loaded.file.name)
assertEquals(pngBytes().size.toLong(), loaded.file.length())
val missing = runBlocking { store.load(ImageSource.Server("$base/gone.png")) }
assertTrue("404 必须是失败", missing.isFailure)
assertTrue(
"服务端有话说就用它那句",
missing.exceptionOrNull()!!.message!!.contains("no such file"),
)
assertFalse(DocumentCache.fileFor(cacheRoot, "gone.png")!!.exists())
val empty = runBlocking { store.load(ImageSource.Server("$base/empty.png")) }
assertTrue("0 字节的响应不是一张图", empty.isFailure)
assertFalse(
"空响应绝不落进缓存(否则下一次会「命中」一份什么都没有的文件)",
DocumentCache.fileFor(cacheRoot, "empty.png")!!.exists(),
)
} finally {
server.stop(0)
}
}
// —— 名字推导的边界(这里只验「不会变成路径」这一半,规则本身在 ImagePreviewTest)——
@Test
fun `an unsafe name is never turned into a cache path`() {
assertNull(DocumentCache.fileFor(cacheRoot, "../escape.png"))
assertNull(DocumentCache.fileFor(cacheRoot, "sub/dir.png"))
assertNull(DocumentCache.fileFor(cacheRoot, "\u0000.png"))
}
// —— 夹具 ——
private fun store(
download: suspend (String) -> Result<ByteArray>,
local: Map<String, ByteArray> = emptyMap(),
) = ImageCacheStore(
cacheDir = cacheRoot,
openLocal = { uri -> local[uri]?.inputStream() },
download = download,
)
private fun respond(exchange: HttpExchange, code: Int, body: ByteArray) {
if (body.isEmpty()) exchange.sendResponseHeaders(code, -1) else {
exchange.sendResponseHeaders(code, body.size.toLong())
}
exchange.responseBody.use { it.write(body) }
}
/** 下载失败只需要一个说得清的类型;生产路径上它是 [CyreneException]。 */
private companion object {
const val HEX = "0123456789abcdef0123456789abcdef"
const val NAME = "$HEX.png"
const val SERVER_URL = "http://host/api/v1/files/$NAME"
const val LOCAL_URI = "content://media/picker/0/1"
fun pngBytes(): ByteArray = byteArrayOf(
0x89.toByte(),
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x42,
0x42,
)
}
}
@@ -0,0 +1,226 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
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.repository.DocumentCacheUnavailableException
import top.yeij.cyrene.data.repository.ImageTooLargeException
import top.yeij.cyrene.data.repository.LocalDocumentTooLargeException
import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException
import java.util.Base64
/**
* 图片全屏查看的纯逻辑:来源判定、缓存名推导(含安全性)、字节嗅探、打开/保存的类型、失败文案。
*
* 这些是**全部**能在 JVM 上判定的部分——解码、手势与 Intent 都得真机/仪器测试,本仓库没有
* 那类测试的基础设施,所以它们不进单测(装配部分在交接里明说没测)。
*/
class ImagePreviewTest {
// —— 来源判定 ——
@Test
fun `an upload url is a server source`() {
val source = ImageSource.of("http://10.0.0.10:10942/api/v1/files/$NAME")
assertTrue(source is ImageSource.Server)
assertEquals("http://10.0.0.10:10942/api/v1/files/$NAME", source?.raw)
assertTrue(ImageSource.of("https://example.test/files/$NAME") is ImageSource.Server)
}
@Test
fun `an inline data uri is its own source`() {
assertTrue(ImageSource.of("data:image/png;base64,AAAA") is ImageSource.Inline)
assertTrue("大小写不该改变判定", ImageSource.of("DATA:image/png;base64,AAAA") is ImageSource.Inline)
}
@Test
fun `everything else is a local reference and a blank one is nothing`() {
assertTrue(ImageSource.of("content://media/picker/0/1") is ImageSource.Local)
assertTrue(ImageSource.of("file:///data/user/0/top.yeij.cyrene/cache/shared/a.png") is ImageSource.Local)
// 形态陌生也按本地读:读不到会如实报「读不到这张图片」,绝不静默什么都不发生。
assertTrue(ImageSource.of("/sdcard/Pictures/a.png") is ImageSource.Local)
assertNull(ImageSource.of(""))
assertNull(ImageSource.of(" "))
}
// —— 缓存名:从服务端地址推 ——
@Test
fun `a server url yields the storage name`() {
assertEquals(NAME, ImageNames.fromServerUrl("http://host:10942/api/v1/files/$NAME"))
assertEquals(NAME, ImageNames.fromServerUrl("https://host/api/v1/files/$NAME"))
assertEquals("缓存名只认路径那一段,查询串与锚点不进文件名", NAME, ImageNames.fromServerUrl("http://host/f/$NAME?v=2#x"))
}
@Test
fun `a name that would climb out of the cache directory is refused`() {
assertNull(ImageNames.fromServerUrl("http://host/files/.."))
assertNull(ImageNames.fromServerUrl("http://host/files/."))
assertNull(ImageNames.fromServerUrl("http://host/files/"))
assertNull(ImageNames.fromServerUrl("http://host"))
// 编码过的穿越:本端从不解释 URL 编码,带 % 的段一律当「不是我们认识的那个名字」。
assertNull(ImageNames.fromServerUrl("http://host/files/%2e%2e%2fsecret.png"))
assertNull(ImageNames.fromServerUrl("http://host/files/a%2Fb.png"))
assertNull(ImageNames.fromServerUrl("http://host/files/a\\b.png"))
assertNull(ImageNames.fromServerUrl("http://host/files/a\u0000b.png"))
// 扩展名不在图片白名单里:推不出类型就不拿它当缓存名(回落内容寻址)。
assertNull(ImageNames.fromServerUrl("http://host/files/$HEX.svg"))
assertNull(ImageNames.fromServerUrl("http://host/files/$HEX"))
assertNull(ImageNames.fromServerUrl("http://host/files/$HEX.png.txt"))
}
@Test
fun `a traversal in the middle of the path cannot affect the last segment`() {
// 最后一段是安全的 ⇒ 回它;危险的是「拿整段路径拼文件名」的做法,本端从不那么做。
assertEquals(NAME, ImageNames.fromServerUrl("http://host/files/../../etc/$NAME"))
}
// —— 字节嗅探 ——
@Test
fun `the whitelisted signatures are recognised`() {
assertEquals(ImageFormats.MIME_PNG, ImageFormats.sniff(pngBytes()))
assertEquals(ImageFormats.MIME_JPEG, ImageFormats.sniff(bytes(0xFF, 0xD8, 0xFF, 0xE0)))
assertEquals(ImageFormats.MIME_GIF, ImageFormats.sniff("GIF89a".toByteArray()))
assertEquals(ImageFormats.MIME_WEBP, ImageFormats.sniff(webpBytes()))
assertEquals(ImageFormats.MIME_BMP, ImageFormats.sniff(bmpBytes()))
}
@Test
fun `anything else is not guessed into an image`() {
assertNull("空字节不是图片", ImageFormats.sniff(ByteArray(0)))
assertNull("HTML 错误页不是图片", ImageFormats.sniff("<html><body>502</body></html>".toByteArray()))
assertNull("文本不是图片", ImageFormats.sniff("AABBCC".toByteArray()))
assertNull("只有 RIFF 头的不是 webp", ImageFormats.sniff("RIFF????".toByteArray()))
assertNull("只有 BM 两个字节不够一个 BMP 头", ImageFormats.sniff("BM".toByteArray()))
}
@Test
fun `mime and extension map both ways`() {
assertEquals(ImageFormats.MIME_PNG, ImageFormats.mimeForExtension("a.png"))
assertEquals(ImageFormats.MIME_JPEG, ImageFormats.mimeForExtension("a.JPG"))
assertEquals(ImageFormats.MIME_JPEG, ImageFormats.mimeForExtension("a.jpeg"))
assertNull(ImageFormats.mimeForExtension("a.heic"))
assertNull(ImageFormats.mimeForExtension("no-extension"))
assertEquals(".png", ImageFormats.extensionFor(ImageFormats.MIME_PNG))
assertEquals(".jpg", ImageFormats.extensionFor(ImageFormats.MIME_JPEG))
assertEquals("认不出的类型不给扩展名", "", ImageFormats.extensionFor("image/heic"))
assertEquals("", ImageFormats.extensionFor(null))
}
// —— 内联 data URI ——
@Test
fun `an inline data uri decodes to the same bytes`() {
val bytes = pngBytes()
val dataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(bytes)
assertEquals(ImageFormats.MIME_PNG, ImageDataUri.declaredMime(dataUri))
assertEquals(bytes.size, ImageDataUri.bytes(dataUri)?.size)
}
@Test
fun `a data uri that is not base64 image data decodes to nothing`() {
assertNull(ImageDataUri.bytes("data:image/png,rawbytes"))
assertNull(ImageDataUri.bytes("content://media/1"))
assertNull(ImageDataUri.bytes("data:image/png;base64,"))
assertNull(ImageDataUri.bytes("data:image/png;base64,%%%%"))
assertNull(ImageDataUri.declaredMime("content://media/1"))
}
// —— 打开 / 保存的类型 ——
@Test
fun `the bytes beat every declared type`() {
assertEquals(
"字节是唯一裁决:名字与声明都只是候选",
ImageFormats.MIME_PNG,
ImageOpenTypes.openType(sniffed = ImageFormats.MIME_PNG, declared = "image/jpeg", name = "a.jpg"),
)
}
@Test
fun `a platform declaration is used when the bytes are unknown`() {
assertEquals(
"本地 HEIC:本端说不清,但平台说得清",
"image/heic",
ImageOpenTypes.openType(sniffed = null, declared = "image/heic", name = HEX),
)
}
@Test
fun `an unhelpful declaration falls through to the extension and then to any image`() {
assertEquals(
"octet-stream 声明对看图没有帮助,退到扩展名",
ImageFormats.MIME_PNG,
ImageOpenTypes.openType(sniffed = null, declared = "application/octet-stream", name = "a.png"),
)
assertEquals(
ImageOpenTypes.ANY_IMAGE,
ImageOpenTypes.openType(sniffed = null, declared = null, name = null),
)
assertEquals(
ImageOpenTypes.ANY_IMAGE,
ImageOpenTypes.openType(sniffed = " ", declared = " ", name = "no-extension"),
)
}
@Test
fun `saving always declares the wildcard type`() {
// ⚠️ 这条不是风格问题:CreateDocument 传具体 mime 时,SAF 的 Downloads provider 会按它给
// 文件名补扩展名(2026-10-05 实机复现 .md → .md.txt)。图片这边同样危险:服务端名字的
// 扩展名与字节嗅探的基类型不保证一致,补一次就成了 x.jpg.png。
assertEquals("*/*", ImageOpenTypes.SAVE_MIME)
}
// —— 失败文案 ——
@Test
fun `every local failure gets its own sentence`() {
val messages = listOf(
imageLoadFailureMessage(LocalDocumentUnavailableException()),
imageLoadFailureMessage(LocalDocumentTooLargeException()),
imageLoadFailureMessage(ImageTooLargeException()),
imageLoadFailureMessage(DocumentCacheUnavailableException()),
)
assertTrue(messages.all { it.isNotBlank() })
assertEquals("读不到 / 太大 / 缓存不可用 三种处境各有各的话", 3, messages.distinct().size)
assertTrue(messages.first().contains("读不到"))
}
@Test
fun `a server failure keeps the servers own words`() {
val notFound = imageLoadFailureMessage(
CyreneException(
CyreneError.Server(code = "document_not_found", message = "document not found", httpStatus = 404),
),
)
val other = imageLoadFailureMessage(
CyreneException(CyreneError.Server(code = "oops", message = "磁盘满了", httpStatus = 500)),
)
assertEquals("服务端上已经没有这张图片了", notFound)
assertTrue("未知码也要原样带出服务端的话", other.contains("磁盘满了"))
}
// —— 夹具 ——
private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
private fun pngBytes() = bytes(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00)
private fun webpBytes() =
"RIFF".toByteArray() + bytes(0x10, 0, 0, 0) + "WEBP".toByteArray() + bytes(0, 0)
private fun bmpBytes() = "BM".toByteArray() + ByteArray(12)
private companion object {
const val HEX = "0123456789abcdef0123456789abcdef"
const val NAME = "$HEX.png"
}
}
@@ -0,0 +1,189 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 全屏查看的缩放 / 平移数学。
*
* 这里锚四件事:**缩放钳制**(1x…3x,非有限值也不许漏出去)、**双击的开关语义**、
* **位移钳制**(fit 态拖不动、放大后拖不到露出空白)、以及**焦点不动**(双指捏合/双击时,
* 手指按住的那个点在屏幕上不动——这是「缩放跟手」的全部内容)。
*
* 手势事件本身(`detectTransformGestures` / `detectTapGestures`)不进单测:那要仪器测试,
* 本仓库没有那套基础设施。这里测的是它们**唯一**的落点。
*/
class ImageZoomTest {
/** 竖屏容器 1080×1920,横屏图 4000×2000(fit 后贴住宽度)。 */
private val container = ImageSize(1080f, 1920f)
private val image = ImageSize(4000f, 2000f)
// —— 缩放钳制 ——
@Test
fun `the scale never leaves one to three`() {
assertEquals(ImageZoom.MIN_SCALE, ImageZoom.clampScale(0.2f), TOLERANCE)
assertEquals(ImageZoom.MIN_SCALE, ImageZoom.clampScale(0f), TOLERANCE)
assertEquals(ImageZoom.MIN_SCALE, ImageZoom.clampScale(-4f), TOLERANCE)
assertEquals(ImageZoom.MAX_SCALE, ImageZoom.clampScale(9f), TOLERANCE)
assertEquals(2.5f, ImageZoom.clampScale(2.5f), TOLERANCE)
}
@Test
fun `a non finite scale falls back to fit instead of poisoning the offsets`() {
assertEquals(ImageZoom.MIN_SCALE, ImageZoom.clampScale(Float.NaN), TOLERANCE)
assertEquals(ImageZoom.MIN_SCALE, ImageZoom.clampScale(Float.POSITIVE_INFINITY), TOLERANCE)
assertEquals(0f, ImageZoom.clampOffset(Float.NaN, 1080f, 1080f, 2f), TOLERANCE)
assertEquals(0f, ImageZoom.maxOffset(Float.NaN, 1080f, 2f), TOLERANCE)
}
// —— 双击的开关语义 ——
@Test
fun `double tap toggles between fit and the double tap scale`() {
assertEquals(ImageZoom.DOUBLE_TAP_SCALE, ImageZoom.doubleTapScale(ImageZoom.MIN_SCALE), TOLERANCE)
assertEquals(ImageZoom.MIN_SCALE, ImageZoom.doubleTapScale(ImageZoom.DOUBLE_TAP_SCALE), TOLERANCE)
assertEquals(
"已经放大到 3x 时双击是收回,不是继续放大",
ImageZoom.MIN_SCALE,
ImageZoom.doubleTapScale(3f),
TOLERANCE,
)
assertFalse(ImageZoom.isZoomed(ImageZoom.MIN_SCALE))
assertTrue(ImageZoom.isZoomed(ImageZoom.DOUBLE_TAP_SCALE))
}
// —— fit-center 的尺寸 ——
@Test
fun `fit center scales the image to touch one side of the container`() {
val fitted = ImageZoom.fittedSize(container, image)
assertEquals("宽的一边贴住容器", 1080f, fitted.width, TOLERANCE)
assertEquals(540f, fitted.height, TOLERANCE)
}
@Test
fun `an unmeasured container or image yields no content size`() {
assertEquals(ImageSize(0f, 0f), ImageZoom.fittedSize(ImageSize(0f, 0f), image))
assertEquals(ImageSize(0f, 0f), ImageZoom.fittedSize(container, ImageSize(0f, 0f)))
}
// —— 位移钳制 ——
@Test
fun `at fit there is nothing to pan`() {
assertEquals(0f, ImageZoom.maxOffset(1080f, 1080f, ImageZoom.MIN_SCALE), TOLERANCE)
val panned = ImageZoom.pan(ZoomPan(), deltaX = 400f, deltaY = 400f, container = container, image = image)
assertEquals(ImageZoom.MIN_SCALE, panned.scale, TOLERANCE)
assertEquals("fit 态下拖不动:内容没超出容器", 0f, panned.offsetX, TOLERANCE)
assertEquals(0f, panned.offsetY, TOLERANCE)
}
@Test
fun `zoomed in the pan stops exactly where the content edge meets the container edge`() {
// 2x 下内容 2160×1080:横向超出 1080 ⇒ 两轴各能移 540;纵向贴满 ⇒ 0。
val limit = ImageZoom.maxOffset(1080f, 1080f, 2f)
assertEquals(540f, limit, TOLERANCE)
val far = ImageZoom.pan(
ZoomPan(scale = 2f),
deltaX = 5_000f,
deltaY = 5_000f,
container = container,
image = image,
)
assertEquals("拖到头就停,绝不把图拖出可视区", 540f, far.offsetX, TOLERANCE)
assertEquals(0f, far.offsetY, TOLERANCE)
}
@Test
fun `a zoomed image can be dragged back and forth inside the limit`() {
val right = ImageZoom.pan(ZoomPan(scale = 2f), 100f, 0f, container, image)
val left = ImageZoom.pan(right, -100f, 0f, container, image)
assertEquals(100f, right.offsetX, TOLERANCE)
assertEquals(0f, left.offsetX, TOLERANCE)
}
// —— 焦点不动 ——
@Test
fun `the point under the fingers stays where it is while zooming`() {
// 方形容器 + 方图:两轴都会超出容器,因此两轴都能验焦点不动(不会有一轴被钳到 0)。
val square = ImageSize(1080f, 1080f)
val squareImage = ImageSize(1000f, 1000f)
val current = ZoomPan(scale = 1f)
val focusX = 300f
val focusY = -200f
// 缩放前,焦点下那个点的内容坐标。
val contentX = (focusX - current.offsetX) / current.scale
val contentY = (focusY - current.offsetY) / current.scale
val zoomed = ImageZoom.zoomAround(current, 2f, focusX, focusY, square, squareImage)
assertEquals("缩放后那个点必须还在同一处", focusX, zoomed.offsetX + contentX * zoomed.scale, TOLERANCE)
assertEquals(focusY, zoomed.offsetY + contentY * zoomed.scale, TOLERANCE)
assertEquals(2f, zoomed.scale, TOLERANCE)
}
@Test
fun `a focus point beyond the pan limit is clamped instead of showing blank space`() {
// 横图在 2x 下纵向不超出容器(540×2 = 1080 < 1920)⇒ 纵轴恒为 0,焦点保持在纵轴上做不到。
val zoomed = ImageZoom.zoomAround(ZoomPan(), 2f, 0f, 300f, container, image)
assertEquals(0f, zoomed.offsetY, TOLERANCE)
}
@Test
fun `zooming past the maximum keeps the maximum`() {
val zoomed = ImageZoom.zoomAround(ZoomPan(scale = 2f), 99f, 0f, 0f, container, image)
assertEquals(ImageZoom.MAX_SCALE, zoomed.scale, TOLERANCE)
}
@Test
fun `zooming back to fit also resets the offsets`() {
val zoomed = ImageZoom.doubleTap(ZoomPan(), 400f, 300f, container, image)
val back = ImageZoom.doubleTap(zoomed, 400f, 300f, container, image)
assertEquals(ImageZoom.MIN_SCALE, back.scale, TOLERANCE)
assertEquals("收回 fit 时位移必须归零,否则图会歪在一边", 0f, back.offsetX, TOLERANCE)
assertEquals(0f, back.offsetY, TOLERANCE)
}
@Test
fun `a pinch at fit that only pans does not invent an offset`() {
val panOnly = ImageZoom.zoomAround(ZoomPan(), 1f, 200f, 200f, container, image)
assertEquals(ImageZoom.MIN_SCALE, panOnly.scale, TOLERANCE)
assertEquals(0f, panOnly.offsetX, TOLERANCE)
assertEquals(0f, panOnly.offsetY, TOLERANCE)
}
@Test
fun `an image taller than the container is clamped on the other axis`() {
// 竖图 2000×4000 在同一个竖屏容器里:fit 后 960×1920(贴高的那一边),
// 2x 后 1920×3840 ⇒ 纵向各能移 (3840-1920)/2 = 960,横向各能移 (1920-1080)/2 = 420。
val portrait = ImageSize(2000f, 4000f)
val fitted = ImageZoom.fittedSize(container, portrait)
val far = ImageZoom.pan(ZoomPan(scale = 2f), 5_000f, 5_000f, container, portrait)
assertEquals(960f, fitted.width, TOLERANCE)
assertEquals(1920f, fitted.height, TOLERANCE)
assertEquals(420f, far.offsetX, TOLERANCE)
assertEquals(960f, far.offsetY, TOLERANCE)
}
private companion object {
const val TOLERANCE = 0.01f
}
}