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