diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/DocumentCache.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/DocumentCache.kt index fe510bc..6d7b7a3 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/repository/DocumentCache.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/DocumentCache.kt @@ -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(`` 也带不了头)。 + * 所以这里不发 `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 = ::httpGetImage, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + + /** + * 取回一张图的字节并确保它已落进缓存。 + * + * 三种来源的产物是同一个形状([CachedDocument]):文件名 + 字节。这样「用其它应用打开 / + * 保存到下载目录」两条出口不必按来源分支——交给 FileProvider 的永远是缓存里那一份。 + */ + suspend fun load(source: ImageSource): Result = 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 { + 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 { + val bytes = ImageDataUri.bytes(dataUri) + ?: return Result.failure(LocalDocumentUnavailableException()) + return write(contentAddressed(bytes), bytes) + } + + private fun loadLocal(uri: String): Result { + 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? { + 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 { + 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 = 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))) + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ImagePreview.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ImagePreview.kt new file mode 100644 index 0000000..5b0b9fc --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ImagePreview.kt @@ -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):///api/v1/files/`);读侧免鉴权。 */ + 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>.` 名字的一批。刻意**不**把 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>.`,落进 `/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 -> "图片加载失败,请稍后重试" +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ImageZoom.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ImageZoom.kt new file mode 100644 index 0000000..deabced --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ImageZoom.kt @@ -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), + ) + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index 6c06e69..1b03fd0 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -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(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 }, + ) + } } /** diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ImagePreviewDialog.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ImagePreviewDialog.kt new file mode 100644 index 0000000..8a38218 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ImagePreviewDialog.kt @@ -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(null) } + // Coil 解不开这份字节(拿到了、但不是它能认的图):与「取字节失败」分开说——两件事 + // 要用户做的事不一样,前者可以重试,后者换了网络也没用。 + var undecodable by remember(source, retry) { mutableStateOf(false) } + + val state by produceState( + 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 diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt index ab3f699..b558f42 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt @@ -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) }, ) } diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 8659d6d..92052a0 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -2,10 +2,10 @@ () + 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() + 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, + local: Map = 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, + ) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ImagePreviewTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ImagePreviewTest.kt new file mode 100644 index 0000000..226e5bc --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ImagePreviewTest.kt @@ -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("502".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" + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ImageZoomTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ImageZoomTest.kt new file mode 100644 index 0000000..9b92c99 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ImageZoomTest.kt @@ -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 + } +}