merge: W70 —— 附件预览下方加元数据小字(文本:行数/服务端 chars/截断标记;图片:格式/分辨率/大小/EXIF 拍摄日期;拿不到的项一律省略)
This commit is contained in:
commit
1055f03b46
7 files changed
+830
-16
No files matched your search
@@ -0,0 +1,207 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 预览内容**下方**那一行灰色小字的组装(纯函数,逐档穷举单测在 `PreviewMetadataTest`)。
|
||||
*
|
||||
* ## 这一行是什么,不是什么
|
||||
* 它只放**关于这份文件本身**的格式事实:有多少行、按什么口径解码、多大、多少像素、什么时候拍的。
|
||||
* 它**不是**状态栏、也不是错误提示——「读不到 / 打开失败 / 保存失败」各有自己的去处(芯片行、
|
||||
* 正文区那句人话、弹层下方那行告知),混进来会让这一行读起来像出了什么事。
|
||||
*
|
||||
* ## 纪律:拿不到的项就不出现
|
||||
* 每一项都在**有证据时才产出**,没有证据就整项缺席——**不留占位符、不写「未知」、不写 `--`**。
|
||||
* 一屏小字里堆三个「未知」换不来任何信息,却会让人以为本端查过了。三档证据强度各自成条:
|
||||
* - **字节**(本端真的持有那一份):行数、像素、EXIF 拍摄日期、文件体积;
|
||||
* - **服务端结论**(DTO 里的字段):注入字符数、注入是否被截断、嗅探所得的 mime;
|
||||
* - **本端口径**(本端自己做了什么):`UTF-8` 只是**本端解码口径**,不是文件的真实编码(见下)。
|
||||
*
|
||||
* ## 「编码」为什么敢写 UTF-8(且只敢这么写)
|
||||
* 客户端**拿不到文件的真实编码**:磁盘上那份字节是按什么字符集写的,没有任何字段说过,本端也没有
|
||||
* 可靠的方式判定(猜 GBK / Shift-JIS 就是在编)。能确定的只有一件事:正文区那段文字是
|
||||
* [DocumentPreview.slice] 用 `String(bytes, Charsets.UTF_8)` 解出来的——所以这一项写的是
|
||||
* **「本端解码口径」**,即「你正在读的这段文字是怎么来的」。**表述里必须带这个限定语**:
|
||||
* 裸写一个「UTF-8」会被读成「这份文件是 UTF-8 编码的」,那是本端不知道的事。
|
||||
*
|
||||
* ## 与芯片行重合的两处:一处保留、一处省略
|
||||
* 预览弹层顶部那枚芯片已经写着「类型 · 体积 · 字数 · 状态 · 注入已截断」([DocumentChipModel.detail]),
|
||||
* 因此这一行刻意**不重复**:
|
||||
* - **省略**「体积」与「服务端状态」:[DocumentChips.sizeLabel] / [DocumentChips.statusLabel] 那两个
|
||||
* 数字/词与芯片行**逐字相同**、且没有口径可补,再写一遍只是噪音(图片那一路没有芯片行,所以
|
||||
* 体积在 [imageItems] 里是要显示的);
|
||||
* - **保留**「字符数」并补上口径:芯片只写「8,000 字」,读者无法知道那是**服务端注入上下文**的
|
||||
* 字数还是这份文件的字数——这正是一句会把人带偏的话,补上「(服务端注入口径)」才算说清。
|
||||
*
|
||||
* ## 本端的截断为什么不在这里再说一遍
|
||||
* [PreviewSlice.truncated](本端只预览了前 256 KB / 5,000 行)已经让 [PreviewSlice.notice] 在**正文
|
||||
* 上方**明说了,本行不再重复同一件事;而**服务端**的「注入被预算截断」是另一件事([DocumentRefDto.truncated],
|
||||
* 正文照样进了上下文),它没有别的去处,所以在 [textItems] 里单列一条。
|
||||
*/
|
||||
object PreviewMetadata {
|
||||
|
||||
/** 这一行各项之间的分隔符。 */
|
||||
const val ITEM_SEPARATOR: String = " · "
|
||||
|
||||
/** 把各项拼成能直接交给 `Text` 的那一行;空列表拼成空串(调用方据此不画这一行)。 */
|
||||
fun join(items: List<String>): String = items.joinToString(ITEM_SEPARATOR)
|
||||
|
||||
/**
|
||||
* 文本类预览的那几项:行数 · 字符数(带口径)· 解码口径 · 服务端的截断标记。
|
||||
*
|
||||
* @param slice 正文区的切段结果([DocumentPreview.slice] 的产物)。
|
||||
* @param serverCharsLabel 服务端下发的注入字符数**已格式化**的那一份
|
||||
* (`documents[].chars` → [DocumentChips.charsLabel],如 `"8,000 字"`);`null` = 服务端没报
|
||||
* (`chars` 整键缺席,契约 v0.59 §1.2),此时改报本端预览内容的字数。
|
||||
* @param contextTruncated 服务端是否报告了「注入被预算截断」(`documents[].truncated`)。
|
||||
* ⚠️ 与 [PreviewSlice.truncated](**本端**预览截断)是两件事,见本对象 KDoc。
|
||||
*/
|
||||
fun textItems(
|
||||
slice: PreviewSlice,
|
||||
serverCharsLabel: String?,
|
||||
contextTruncated: Boolean,
|
||||
): List<String> = buildList {
|
||||
add(linesItem(slice))
|
||||
add(charsItem(slice.text, serverCharsLabel))
|
||||
// 本端解码口径:正文就是 String(bytes, UTF_8) 的产物,说得出、也只在说这一件事。
|
||||
add(DECODED_AS_UTF8)
|
||||
if (contextTruncated) add(TRUNCATED_FOR_CONTEXT)
|
||||
}
|
||||
|
||||
/**
|
||||
* 非文本类(PDF 这类二进制)的那几项:只有服务端**字节嗅探**所得的 mime。
|
||||
*
|
||||
* 体积与状态刻意不在这里(芯片行已经写着,见本对象 KDoc);服务端说「读不到」时下发的
|
||||
* **原因原话**也不在这里——它已经被 [DocumentPreviewGate.plan] 送进正文区那句人话里**原样显示**,
|
||||
* 这里是同一屏的第二个位置,重复一遍只会让那句话看着像两条不同的消息。
|
||||
*
|
||||
* @param serverMime DTO 的 `mime`(服务端嗅探所得基类型);本地待发附件**没有**这个结论
|
||||
* (还没上传过),传 `null` —— 这一项因此缺席,**不由本端按扩展名补一个**。
|
||||
*/
|
||||
fun fileItems(serverMime: String?): List<String> =
|
||||
listOfNotNull(serverMime?.trim()?.takeIf { it.isNotEmpty() })
|
||||
|
||||
/**
|
||||
* 图片的那几项:格式(mime)· 分辨率 · 体积 · 拍摄日期。
|
||||
*
|
||||
* 图片这一路没有附件芯片行,所以体积在这里是要显示的。每一项都可能在拿不到时缺席:
|
||||
* 格式(嗅探与平台声明都判不出来时)、分辨率(本端解不出边界时)、拍摄日期(EXIF 里没有
|
||||
* `DateTimeOriginal`,或那个值不是能认的日期时)——见各自的参数说明。
|
||||
*
|
||||
* @param mime 交给别的应用的类型([ImageOpenTypes.openType] 的产物)。⚠️ 退化到「图片通配」
|
||||
* 时**必须传 `null`**:`image/` 通配([ImageOpenTypes.ANY_IMAGE])不是格式,写出来等于给了一个什么都没说的答案。
|
||||
* @param width / @param height 本端按 `inJustDecodeBounds` 读到的像素;任一侧 ≤ 0 / 缺席即整项不出现。
|
||||
* @param sizeBytes 本端实际持有的字节数;[UNKNOWN_BYTES] 即整项不出现。
|
||||
* @param capturedAt EXIF `DateTimeOriginal` 的**原始字符串**(`yyyy:MM:dd HH:mm:ss`,
|
||||
* 没有时区、按本地墙钟读,见 [formatExifDateTime])。
|
||||
*/
|
||||
fun imageItems(
|
||||
mime: String?,
|
||||
width: Int? = null,
|
||||
height: Int? = null,
|
||||
sizeBytes: Long = UNKNOWN_BYTES,
|
||||
capturedAt: String? = null,
|
||||
): List<String> = buildList {
|
||||
mime?.trim()?.takeIf { it.isNotEmpty() }?.let { add(it) }
|
||||
if (width != null && height != null && width > 0 && height > 0) add("$width × $height")
|
||||
DocumentChips.sizeLabel(sizeBytes).takeIf { it.isNotEmpty() }?.let { add(it) }
|
||||
formatExifDateTime(capturedAt)?.let { add(CAPTURED_AT_PREFIX + it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* EXIF 的拍摄时间 → 人能读的样子(`"2019:07:04 20:31:05"` → `"拍摄于 2019年7月4日 20:31"`);
|
||||
* 认不出就回 `null`(宁可不显示,也不显示一个错的日期)。
|
||||
*
|
||||
* ## 两个必须说清的取舍
|
||||
* 1. **没有时区**。EXIF 的 `DateTimeOriginal` 是纯墙钟字符串(`yyyy:MM:dd HH:mm:ss`),规范里
|
||||
* 根本没有偏移量字段。所以这里**不做任何时区换算**,按字面数字当日历时间读——把它当 UTC
|
||||
* 再转本地,只会让一个原本正确的日期凭空错几个小时。
|
||||
* 2. **严格到不近人情**。只有逐位对上 `yyyy:MM:dd HH:mm:ss`(19 个字符、分隔符固定)且各段落在
|
||||
* 合法范围内才认。相机的「值未写入」占位符 `"0000:00:00 00:00:00"` 因此落在月份 0 上被判非法
|
||||
* ——这是对的,它本来就表示「不知道」。宽松解析(补零、猜分隔符)换来的是**看起来正常的错日期**,
|
||||
* 比不显示糟得多。
|
||||
*
|
||||
* 秒刻意丢掉:这一行是给人看的摘要,分钟已经足够定位(同秒的两张图对用户没有区别)。
|
||||
*/
|
||||
fun formatExifDateTime(raw: String?): String? {
|
||||
val value = raw?.trim().orEmpty()
|
||||
val groups = EXIF_DATE_TIME.matchEntire(value) ?: return null
|
||||
// 正则已保证每段都是纯数字,`toInt()` 在这里不会抛。
|
||||
val (year, month, day, hour, minute, second) = groups.destructured
|
||||
val y = year.toInt()
|
||||
val mo = month.toInt()
|
||||
val d = day.toInt()
|
||||
val h = hour.toInt()
|
||||
val mi = minute.toInt()
|
||||
val s = second.toInt()
|
||||
if (y < MIN_YEAR) return null
|
||||
if (mo !in 1..12) return null
|
||||
if (d !in 1..31) return null
|
||||
if (h !in 0..23) return null
|
||||
if (mi !in 0..59) return null
|
||||
// 60 是闰秒,相机会写出来,不该判成坏日期。
|
||||
if (s !in 0..60) return null
|
||||
return String.format(Locale.ROOT, "%d年%d月%d日 %02d:%02d", y, mo, d, h, mi)
|
||||
}
|
||||
|
||||
/**
|
||||
* 行数那一项。
|
||||
*
|
||||
* 显示量按 [DocumentPreview.lineCount] **从正文本身重算**(不是取 [PreviewSlice.shownLines]):
|
||||
* 这一行的标题就是「你看到的这段有多少行」,重算让它与正文永远自洽。被本端截断时补上全文行数
|
||||
* ([PreviewSlice.totalLines] 是按整份字节数出来的,本端确实持有那份字节)——只说「显示 N 行」
|
||||
* 会让人以为文件就这么长。
|
||||
*/
|
||||
private fun linesItem(slice: PreviewSlice): String {
|
||||
val shown = grouped(DocumentPreview.lineCount(slice.text))
|
||||
return if (slice.truncated) {
|
||||
"显示 $shown 行 / 共 ${grouped(slice.totalLines)} 行"
|
||||
} else {
|
||||
"$shown 行"
|
||||
}
|
||||
}
|
||||
|
||||
/** 字符数一项:服务端报了就报它那句(补口径),没报才用本端的计数(另一句口径)。 */
|
||||
private fun charsItem(text: String, serverCharsLabel: String?): String =
|
||||
if (serverCharsLabel != null) {
|
||||
serverCharsLabel + SERVER_CHARS_SCOPE
|
||||
} else {
|
||||
grouped(codePointCount(text)) + " 字" + LOCAL_CHARS_SCOPE
|
||||
}
|
||||
|
||||
/**
|
||||
* 「字」数按 **Unicode 码点**算(`codePointCount`):一个 emoji / 一个汉字都算一个字。
|
||||
* 直接取 `String.length` 会把 BMP 之外的字符算成两个(UTF-16 代理对),与「字数」这个说法不符。
|
||||
*/
|
||||
private fun codePointCount(text: String): Int =
|
||||
text.codePointCount(0, text.length)
|
||||
|
||||
/** 千分位分组(只用于计数,纯展示)。 */
|
||||
private fun grouped(value: Int): String = String.format(Locale.ROOT, "%,d", value)
|
||||
|
||||
// —— 每一项的措辞都收在这里,测试锚的也是这几句 ——
|
||||
|
||||
/** 本端解码口径。**限定语不许省**:省了就成了对文件真实编码的断言(见本对象 KDoc)。 */
|
||||
const val DECODED_AS_UTF8: String = "UTF-8(本端解码口径)"
|
||||
|
||||
/** 服务端 `chars` 的口径:那是**注入上下文**的字数,不是这份文件的字数。 */
|
||||
const val SERVER_CHARS_SCOPE: String = "(服务端注入口径)"
|
||||
|
||||
/** 本端自己数出来的字数口径:只数了预览内容(截断时就不是全文)。 */
|
||||
const val LOCAL_CHARS_SCOPE: String = "(本端预览内容)"
|
||||
|
||||
/** 服务端的 `truncated`:注入被预算截断,**正文照样进了上下文**(与「读不到」是两件事)。 */
|
||||
const val TRUNCATED_FOR_CONTEXT: String = "正文被截断(进上下文的部分)"
|
||||
|
||||
/** 拍摄日期前缀:一个光秃秃的日期在这一行里会被当成「文件修改时间」之类的东西。 */
|
||||
const val CAPTURED_AT_PREFIX: String = "拍摄于 "
|
||||
|
||||
/** 字节数拿不到时的占位:交给 [DocumentChips.sizeLabel],它会回空串(于是整项不出现)。 */
|
||||
const val UNKNOWN_BYTES: Long = -1L
|
||||
|
||||
private const val MIN_YEAR = 1
|
||||
|
||||
/** EXIF 规范里的日期时间形状;只认这一种,见 [formatExifDateTime]。 */
|
||||
private val EXIF_DATE_TIME =
|
||||
Regex("""(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})""")
|
||||
}
|
||||
@@ -22,7 +22,9 @@ import androidx.compose.ui.window.Dialog
|
||||
import top.yeij.cyrene.data.repository.CachedDocument
|
||||
import top.yeij.cyrene.domain.chat.DocumentPreview
|
||||
import top.yeij.cyrene.domain.chat.DocumentPreviewPlan
|
||||
import top.yeij.cyrene.domain.chat.DocumentSource
|
||||
import top.yeij.cyrene.domain.chat.DocumentTarget
|
||||
import top.yeij.cyrene.domain.chat.PreviewMetadata
|
||||
import top.yeij.cyrene.domain.chat.PreviewSlice
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||
@@ -85,6 +87,15 @@ data class DocumentPreviewNotice(val message: String, val error: Boolean = false
|
||||
* [PreviewSlice.notice] 非空时在正文上方明写「只显示前 N」并指路另外两条出口——**绝不静默
|
||||
* 截断**:用户看到一段像是完整的正文却其实是前 256 KB,是最容易被误读的一种失败。
|
||||
*
|
||||
* ## 正文下方那一行元数据
|
||||
* 正文(非文本时是那句人话)**下方**还有一行灰色小字,写的是「这份文件是什么」的格式事实:
|
||||
* 文本给行数 / 字符数(带口径)/ 解码口径 / 服务端的截断标记,非文本给服务端嗅探所得的 mime。
|
||||
* 组装全在纯函数 [PreviewMetadata] 里(**「为什么某一项不显示」也写在那里**),这里只取证据、画一行。
|
||||
*
|
||||
* 它与上方那枚芯片刻意**不重复**:体积与服务端状态芯片上已经写着(同一个数字、同一个词,再写
|
||||
* 一遍只是噪音);字符数虽然也在芯片上,但芯片那个「8,000 字」没说清是谁的口径——本行补上
|
||||
* 「(服务端注入口径)」,这才不算一句会把用户带偏的话。
|
||||
*
|
||||
* @param target 被点开的那一枚芯片(来源判定见 [DocumentTarget.of],弹层不自己判断)。
|
||||
* @param plan 正文区能不能按文本渲染([DocumentTarget.previewPlan] 的产物,弹层不自己判断)。
|
||||
* @param notice 「用其它应用打开 / 保存」这一步的如实告知(没有 App 能开、保存成功或失败);
|
||||
@@ -135,22 +146,25 @@ fun DocumentPreviewDialog(
|
||||
TextButton("重试", onClick = onRetry)
|
||||
}
|
||||
|
||||
is DocumentPreviewState.Ready -> when (plan) {
|
||||
DocumentPreviewPlan.Text -> {
|
||||
state.slice.notice?.let { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
is DocumentPreviewState.Ready -> {
|
||||
when (plan) {
|
||||
DocumentPreviewPlan.Text -> {
|
||||
state.slice.notice?.let { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
PreviewBody(state.slice)
|
||||
}
|
||||
PreviewBody(state.slice)
|
||||
}
|
||||
|
||||
// 非文本:**正文区留空**,只留那句人话。字节其实已经取回来了(两条出口要用),
|
||||
// 只是本端不会渲染它——把 PDF 的字节按 UTF-8 显示出来就是一屏乱码。
|
||||
is DocumentPreviewPlan.NotText -> PreviewHint(plan.message)
|
||||
// 非文本:**正文区留空**,只留那句人话。字节其实已经取回来了(两条出口要用),
|
||||
// 只是本端不会渲染它——把 PDF 的字节按 UTF-8 显示出来就是一屏乱码。
|
||||
is DocumentPreviewPlan.NotText -> PreviewHint(plan.message)
|
||||
}
|
||||
PreviewMetadataLine(state = state, plan = plan, target = target)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +213,50 @@ fun DocumentPreviewDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文**下方**那一行灰色小字:关于这份文件的格式元数据([PreviewMetadata] 的产物)。
|
||||
*
|
||||
* 组装全在纯函数里(逐档穷举单测),这里只做两件事:**取证据**、**画一行**。
|
||||
* 取证据的两处来源都不许在 UI 层临场发挥:
|
||||
* - 服务端那两件事实(注入字数 / 注入是否被截断)从[芯片模型][DocumentChipModel]里取——
|
||||
* 它就是 DTO 那几字段的唯一换算落点([DocumentChips.of]);
|
||||
* - 嗅探所得的 mime 只从 [DocumentSource.Server.mime] 取。本地待发附件**没有**这个结论
|
||||
* (那时还没上传过),所以它这一项缺席——**不拿扩展名编一个**(`DocumentTarget.openType()`
|
||||
* 对本地来源回的是 `application/octet-stream` 这个兜底值,拿它当「类型」会是本端编的话)。
|
||||
*
|
||||
* 拼出来是空串时**整行不画**(一项证据都没有就没有这一行),绝不留一个空行或一排「未知」。
|
||||
*/
|
||||
@Composable
|
||||
private fun PreviewMetadataLine(
|
||||
state: DocumentPreviewState.Ready,
|
||||
plan: DocumentPreviewPlan,
|
||||
target: DocumentTarget,
|
||||
) {
|
||||
val items = remember(state.slice, plan, target) {
|
||||
when (plan) {
|
||||
// 文本:行数 / 字符数(带口径)/ 解码口径 / 服务端的截断标记。
|
||||
// `truncatedLabel` 非空 ⟺ DTO 的 `truncated == true`(见 DocumentChipModel)。
|
||||
DocumentPreviewPlan.Text -> PreviewMetadata.textItems(
|
||||
slice = state.slice,
|
||||
serverCharsLabel = target.chips.charsLabel,
|
||||
contextTruncated = target.chips.truncatedLabel != null,
|
||||
)
|
||||
|
||||
// 非文本:只有服务端嗅探所得的 mime(体积与状态已在上面那枚芯片里)。
|
||||
is DocumentPreviewPlan.NotText ->
|
||||
PreviewMetadata.fileItems((target.source as? DocumentSource.Server)?.mime)
|
||||
}
|
||||
}
|
||||
val line = PreviewMetadata.join(items)
|
||||
if (line.isEmpty()) return
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = line,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
|
||||
/** 正文区:按行分块、逐块可选中,块数上限由 [DocumentPreviewLimits] 保证。 */
|
||||
@Composable
|
||||
private fun PreviewBody(slice: PreviewSlice) {
|
||||
|
||||
@@ -54,6 +54,7 @@ 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.PreviewMetadata
|
||||
import top.yeij.cyrene.domain.chat.ZoomPan
|
||||
import top.yeij.cyrene.domain.chat.imageLoadFailureMessage
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
@@ -145,6 +146,28 @@ fun ImagePreviewDialog(
|
||||
)
|
||||
}
|
||||
|
||||
// 预览内容下方那一行元数据要用的事实:像素与 EXIF 拍摄日期都得先读字节,所以放到 IO 上算。
|
||||
// 键是**那一份缓存**:换一张图(或重试拿回新的字节)才重算,重组不会。
|
||||
val facts by produceState(
|
||||
initialValue = PreviewImageFacts(),
|
||||
key1 = (state as? ImagePreviewState.Ready)?.cached,
|
||||
) {
|
||||
val cached = (state as? ImagePreviewState.Ready)?.cached ?: return@produceState
|
||||
value = withContext(Dispatchers.IO) { readPreviewImageFacts(cached.bytes) }
|
||||
}
|
||||
val ready = state as? ImagePreviewState.Ready
|
||||
val metadata = PreviewMetadata.join(
|
||||
PreviewMetadata.imageItems(
|
||||
// 「图片通配」不是格式:嗅探与平台声明都判不出来时这一项就缺席,
|
||||
// **不拿 `image/*` 冒充一个答案**。
|
||||
mime = ready?.openType?.takeIf { it != ImageOpenTypes.ANY_IMAGE },
|
||||
width = facts.width,
|
||||
height = facts.height,
|
||||
sizeBytes = ready?.cached?.bytes?.size?.toLong() ?: PreviewMetadata.UNKNOWN_BYTES,
|
||||
capturedAt = facts.capturedAt,
|
||||
),
|
||||
)
|
||||
|
||||
// 保存到下载目录:与附件那条路同一取舍——MIME 恒 */*,落盘的是缓存里那一份**原始字节**
|
||||
// (不是重新编码过的位图,JPEG 存下来还是 JPEG)。
|
||||
val createDocument = rememberLauncherForActivityResult(
|
||||
@@ -237,7 +260,8 @@ fun ImagePreviewDialog(
|
||||
}
|
||||
|
||||
ExitPane(
|
||||
ready = state as? ImagePreviewState.Ready,
|
||||
ready = ready,
|
||||
metadata = metadata,
|
||||
notice = notice,
|
||||
note = nameNoteFor(source),
|
||||
onOpen = { openElsewhere() },
|
||||
@@ -344,12 +368,16 @@ private fun ZoomableImage(file: File, onUndecodable: () -> Unit) {
|
||||
/**
|
||||
* 底部那一列出口(与附件弹层同一形态)。
|
||||
*
|
||||
* 最上面那一行是**预览内容下方**的格式元数据([PreviewMetadata] 的产物,可能是空串——
|
||||
* 那就整行不画,绝不留一个空行或一排「未知」);再下面是 [notice](刚才那个动作的结果)。
|
||||
*
|
||||
* 字节还没到位([ImagePreviewState.Loading] / [ImagePreviewState.Failed])时两条出口**不给点**:
|
||||
* 点了也只能失败,不如让它们明摆着还没准备好。但**关闭**任何时候都在(右上角那个按钮)。
|
||||
*/
|
||||
@Composable
|
||||
private fun ExitPane(
|
||||
ready: ImagePreviewState.Ready?,
|
||||
metadata: String,
|
||||
notice: DocumentPreviewNotice?,
|
||||
note: String,
|
||||
onOpen: () -> Unit,
|
||||
@@ -360,6 +388,14 @@ private fun ExitPane(
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp, bottom = 12.dp),
|
||||
) {
|
||||
if (metadata.isNotEmpty()) {
|
||||
Text(
|
||||
text = metadata,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier.padding(horizontal = EXIT_TEXT_MARGIN, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
notice?.let { line ->
|
||||
Text(
|
||||
text = line.message,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.ExifInterface
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
/**
|
||||
* 从本端持有的那份字节里**现读**出来的图片事实:像素与拍摄日期。读不到的就是 `null`。
|
||||
*
|
||||
* ## 为什么这一层不在 `domain/` 里
|
||||
* [PreviewMetadata][top.yeij.cyrene.domain.chat.PreviewMetadata] 是纯函数(JVM 单测里逐档穷举),
|
||||
* 而这里调的两个都是 Android 平台类(`android.graphics.BitmapFactory` / `android.media.ExifInterface`),
|
||||
* 在 JVM 单测里是**抛异常的 stub**。所以切口落在这里:读平台 → 得到两个可能为 `null` 的数 →
|
||||
* 交给纯函数组装成那一行。判断「显示什么」的逻辑一个字都不在平台类里,测试因此能覆盖全部档位。
|
||||
*
|
||||
* ## 分辨率为什么用 `inJustDecodeBounds`
|
||||
* 为了一行小字把 20 MiB 的大图解成位图是不可接受的(内存 + 主线程耗时)。`inJustDecodeBounds`
|
||||
* 只读文件头,`outWidth` / `outHeight` 就是答案,`decodeByteArray` 返回 `null` 是有意的。
|
||||
*
|
||||
* ## EXIF 用的是**系统 API**,不引新依赖
|
||||
* `android.media.ExifInterface`(`InputStream` 构造自 API 24 起可用,本工程 `minSdk = 33`)。
|
||||
* 它认不出的容器(PNG / GIF / WebP / BMP 这些没有标准 EXIF 的格式)只会让 `getAttribute` 回
|
||||
* `null`——**那正是「拿不到就整项不缺席」要的答案**,不是错误。⚠️ 它**只读 JPEG 与 RAW**,
|
||||
* 所以「所有图片都有拍摄日期」是错的预期,能报出来的基本只有相机/相册里的 JPEG。
|
||||
*
|
||||
* ## 日期为什么不在这里格式化
|
||||
* EXIF 的 `DateTimeOriginal` 是**没有时区**的墙钟字符串(`yyyy:MM:dd HH:mm:ss`),本层**原样**
|
||||
* 交给 [PreviewMetadata.formatExifDateTime][top.yeij.cyrene.domain.chat.PreviewMetadata.formatExifDateTime]
|
||||
* 去严格校验与格式化——那一步是纯函数,能逐档单测,也能把「不做时区换算」这条取舍写在明面上。
|
||||
*/
|
||||
data class PreviewImageFacts(
|
||||
val width: Int? = null,
|
||||
val height: Int? = null,
|
||||
/** EXIF `DateTimeOriginal` 的原始字符串;缺席即 `null`。 */
|
||||
val capturedAt: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 读出这两个事实。**任何一步失败都只是少一项**(`runCatching`):这一行是锦上添花,
|
||||
* 它自己绝不许成为「图片打不开」的原因。
|
||||
*
|
||||
* 只从**已经在内存里**的那份字节读([top.yeij.cyrene.data.repository.CachedDocument.bytes],
|
||||
* 预览与两条出口用的都是它),不再碰盘、不再联网。
|
||||
*/
|
||||
fun readPreviewImageFacts(bytes: ByteArray): PreviewImageFacts {
|
||||
val bounds = readBounds(bytes)
|
||||
return PreviewImageFacts(
|
||||
width = bounds?.first,
|
||||
height = bounds?.second,
|
||||
capturedAt = readCapturedAt(bytes),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件头里的像素:任一侧 ≤ 0(解不出来 / 不是图片)就是 `null`。
|
||||
*
|
||||
* 与 `ZoomableImage` 里 Coil 报上来的那个尺寸是两回事:那个是**解码后**的位图尺寸,要等图片
|
||||
* 真的解出来才有;这里是**打开弹层时立刻**就有(只看文件头)。两者一致是常态,不一致时以这里
|
||||
* 为准——它描述的是文件本身。
|
||||
*/
|
||||
private fun readBounds(bytes: ByteArray): Pair<Int, Int>? = runCatching {
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
|
||||
val width = options.outWidth
|
||||
val height = options.outHeight
|
||||
if (width > 0 && height > 0) width to height else null
|
||||
}.getOrNull()
|
||||
|
||||
/** EXIF 的 `DateTimeOriginal`;没有这个标签 / 不是 EXIF / 读不动,一律 `null`。 */
|
||||
private fun readCapturedAt(bytes: ByteArray): String? = runCatching {
|
||||
ByteArrayInputStream(bytes).use { stream ->
|
||||
ExifInterface(stream).getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)
|
||||
}
|
||||
}.getOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
@@ -0,0 +1,358 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
|
||||
/**
|
||||
* 预览内容下方那一行元数据的组装(逐档穷举)。
|
||||
*
|
||||
* 这一块的价值全在「**不出现**」上:一屏小字里凭空多出一项(一个假的编码、一个猜的日期、
|
||||
* 一个 `--` 占位符)比少一项糟得多,而且它看起来完全正常。所以这里的断言分两类:
|
||||
* 1. **有证据时逐字钉住那一项**(连限定语一起钉:`UTF-8(本端解码口径)` 少了括号里的四个字
|
||||
* 就从「本端口径」变成了对文件编码的断言);
|
||||
* 2. **没证据时断言它整项缺席**(`assertFalse(line.contains(...))`),而不只是断言列表长度。
|
||||
*
|
||||
* 另有一档是「行数」本身:空文本 / 只有换行 / `\r\n` / 结尾没有换行这四种边界都在
|
||||
* [textItems] 里重算过一次(不是照抄 `PreviewSlice.shownLines`),所以要从**字节**跑起,
|
||||
* 走 [DocumentPreview.slice] 这条真实的路。
|
||||
*/
|
||||
class PreviewMetadataTest {
|
||||
|
||||
// —— 行数:从预览内容算,四个边界 ——
|
||||
|
||||
@Test
|
||||
fun `empty text is zero lines`() {
|
||||
val items = textItems(DocumentPreview.slice(ByteArray(0)))
|
||||
|
||||
assertEquals("0 行", items.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single newline is one line not two`() {
|
||||
val items = textItems(DocumentPreview.slice("\n".toByteArray()))
|
||||
|
||||
assertEquals("1 行", items.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `carriage returns do not add lines`() {
|
||||
// `\r\n` 只数 `\n`:Windows 换行不该被算成两行,`\r` 单独出现也不是换行。
|
||||
val items = textItems(DocumentPreview.slice("a\r\nb\r\nc".toByteArray()))
|
||||
|
||||
assertEquals("3 行", items.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing trailing newline still counts the last line`() {
|
||||
val items = textItems(DocumentPreview.slice("a\nb".toByteArray()))
|
||||
|
||||
assertEquals("2 行", items.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a trailing newline does not count an extra line`() {
|
||||
val items = textItems(DocumentPreview.slice("a\nb\n".toByteArray()))
|
||||
|
||||
assertEquals("2 行", items.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a preview cut short reports both the shown and the total line count`() {
|
||||
// 5000 行上限正好等于上限时不截断;这里给 5001 行,确认「显示 / 共」两个数都在。
|
||||
val bytes = lines(DocumentPreviewLimits.MAX_LINES + 1).toByteArray()
|
||||
val slice = DocumentPreview.slice(bytes)
|
||||
|
||||
assertEquals(PreviewTruncation.Lines, slice.truncatedBy)
|
||||
assertEquals(
|
||||
"显示 ${grouped(DocumentPreviewLimits.MAX_LINES)} 行 / 共 ${grouped(DocumentPreviewLimits.MAX_LINES + 1)} 行",
|
||||
textItems(slice).first(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a complete preview says one number only`() {
|
||||
val items = textItems(DocumentPreview.slice("a\nb\nc".toByteArray()))
|
||||
|
||||
assertFalse(items.first(), items.first().contains("共"))
|
||||
assertFalse(items.first(), items.first().contains("显示"))
|
||||
}
|
||||
|
||||
// —— 字符数:两个口径分得开 ——
|
||||
|
||||
@Test
|
||||
fun `the server character count is shown with its scope`() {
|
||||
val items = textItems(sliceOf("hello"), serverCharsLabel = "8,000 字")
|
||||
|
||||
assertEquals("8,000 字(服务端注入口径)", items[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without a server count the local one is used and says so`() {
|
||||
val items = textItems(sliceOf("你好"), serverCharsLabel = null)
|
||||
|
||||
assertEquals("2 字(本端预览内容)", items[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the local count is code points not utf16 units`() {
|
||||
// 一个 emoji 在 UTF-16 里是两个单元(代理对);「字符数」该数成 1。
|
||||
val items = textItems(sliceOf("😀"), serverCharsLabel = null)
|
||||
|
||||
assertEquals("1 字(本端预览内容)", items[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server count of zero is still a report and is shown`() {
|
||||
// `chars: 0` 是「服务端报了 0」,与「服务端没报」是两件事——不许被归一成后者。
|
||||
val items = textItems(sliceOf("abc"), serverCharsLabel = DocumentChips.charsLabel(0))
|
||||
|
||||
assertEquals("0 字(服务端注入口径)", items[1])
|
||||
}
|
||||
|
||||
// —— 解码口径 ——
|
||||
|
||||
@Test
|
||||
fun `the decoding scope is always stated and never as a bare utf8`() {
|
||||
val items = textItems(sliceOf("x"), serverCharsLabel = null)
|
||||
|
||||
assertTrue(items.toString(), items.contains("UTF-8(本端解码口径)"))
|
||||
assertFalse("裸写 UTF-8 会被读成「这份文件是 UTF-8 编码的」", items.contains("UTF-8"))
|
||||
}
|
||||
|
||||
// —— 服务端的 truncated:两态 ——
|
||||
|
||||
@Test
|
||||
fun `the server truncation flag is spelled out when set`() {
|
||||
val items = textItems(sliceOf("x"), serverCharsLabel = null, contextTruncated = true)
|
||||
|
||||
assertEquals("正文被截断(进上下文的部分)", items.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is said about truncation when the server did not report it`() {
|
||||
val items = textItems(sliceOf("x"), serverCharsLabel = null, contextTruncated = false)
|
||||
|
||||
assertFalse(items.toString(), items.any { it.contains("截断") })
|
||||
assertEquals(3, items.size)
|
||||
}
|
||||
|
||||
// —— 非文本档:只有嗅探所得的 mime ——
|
||||
|
||||
@Test
|
||||
fun `a binary file shows the sniffed mime`() {
|
||||
assertEquals(listOf("application/pdf"), PreviewMetadata.fileItems("application/pdf"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a local draft without a server mime shows nothing at all`() {
|
||||
assertEquals(emptyList<String>(), PreviewMetadata.fileItems(null))
|
||||
assertEquals(emptyList<String>(), PreviewMetadata.fileItems(""))
|
||||
assertEquals(emptyList<String>(), PreviewMetadata.fileItems(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a binary file line never invents a placeholder`() {
|
||||
val line = PreviewMetadata.join(PreviewMetadata.fileItems(null))
|
||||
|
||||
assertEquals("", line)
|
||||
assertFalse(line.contains("未知"))
|
||||
assertFalse(line.contains("--"))
|
||||
}
|
||||
|
||||
// —— 图片:四项各自的可有可无 ——
|
||||
|
||||
@Test
|
||||
fun `a full image line carries format, size in pixels, bytes and the shot date`() {
|
||||
val items = PreviewMetadata.imageItems(
|
||||
mime = "image/jpeg",
|
||||
width = 4032,
|
||||
height = 3024,
|
||||
sizeBytes = 2_411_724,
|
||||
capturedAt = "2019:07:04 20:31:05",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("image/jpeg", "4032 × 3024", "2.3 MB", "拍摄于 2019年7月4日 20:31"),
|
||||
items,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an image with no exif simply has no date`() {
|
||||
val items = PreviewMetadata.imageItems(
|
||||
mime = "image/png",
|
||||
width = 100,
|
||||
height = 50,
|
||||
sizeBytes = 2048,
|
||||
capturedAt = null,
|
||||
)
|
||||
|
||||
assertEquals(listOf("image/png", "100 × 50", "2.0 KB"), items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an undecodable size is left out rather than guessed`() {
|
||||
val items = PreviewMetadata.imageItems(mime = "image/gif", width = null, height = null)
|
||||
|
||||
assertEquals(listOf("image/gif"), items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a half known size is not reported`() {
|
||||
// `inJustDecodeBounds` 只会两侧都给或两侧都不给;真出现半边时宁可不报。
|
||||
val items = PreviewMetadata.imageItems(mime = null, width = 800, height = null)
|
||||
|
||||
assertEquals(emptyList<String>(), items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non positive sizes are not reported`() {
|
||||
val items = PreviewMetadata.imageItems(mime = null, width = -1, height = 0)
|
||||
|
||||
assertEquals(emptyList<String>(), items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown byte count leaves the size out`() {
|
||||
val items = PreviewMetadata.imageItems(
|
||||
mime = null,
|
||||
sizeBytes = PreviewMetadata.UNKNOWN_BYTES,
|
||||
)
|
||||
|
||||
assertEquals(emptyList<String>(), items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the image wildcard is not a format and must not be shown`() {
|
||||
// 调用方在这一档传的是 null(`image/` 通配由弹层滤掉);纯函数这层再确认一遍空值不产出项。
|
||||
assertEquals(emptyList<String>(), PreviewMetadata.imageItems(mime = null))
|
||||
assertEquals(emptyList<String>(), PreviewMetadata.imageItems(mime = " "))
|
||||
}
|
||||
|
||||
// —— EXIF 日期:严格到不近人情 ——
|
||||
|
||||
@Test
|
||||
fun `a canonical exif timestamp is formatted`() {
|
||||
assertEquals("2019年7月4日 20:31", PreviewMetadata.formatExifDateTime("2019:07:04 20:31:05"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the time is zero padded and the date is not`() {
|
||||
assertEquals("2001年1月2日 03:04", PreviewMetadata.formatExifDateTime("2001:01:02 03:04:09"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `surrounding whitespace is tolerated`() {
|
||||
assertEquals("2019年7月4日 20:31", PreviewMetadata.formatExifDateTime(" 2019:07:04 20:31:05 "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the all zero placeholder means unknown and is rejected`() {
|
||||
assertNull(PreviewMetadata.formatExifDateTime("0000:00:00 00:00:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out of range components are rejected`() {
|
||||
assertNull(PreviewMetadata.formatExifDateTime("2019:13:04 20:31:05"))
|
||||
assertNull(PreviewMetadata.formatExifDateTime("2019:07:32 20:31:05"))
|
||||
assertNull(PreviewMetadata.formatExifDateTime("2019:07:04 24:31:05"))
|
||||
assertNull(PreviewMetadata.formatExifDateTime("2019:07:04 20:61:05"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a leap second is a valid exif value`() {
|
||||
assertEquals("2016年12月31日 23:59", PreviewMetadata.formatExifDateTime("2016:12:31 23:59:60"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed values are rejected rather than repaired`() {
|
||||
assertNull("另一个分隔符的形态不当成 EXIF 认", PreviewMetadata.formatExifDateTime("2019-07-04 20:31:05"))
|
||||
assertNull(PreviewMetadata.formatExifDateTime("2019:07:04T20:31:05"))
|
||||
assertNull("缺秒", PreviewMetadata.formatExifDateTime("2019:07:04 20:31"))
|
||||
assertNull(PreviewMetadata.formatExifDateTime("2019:7:4 20:31:05"))
|
||||
assertNull(PreviewMetadata.formatExifDateTime("hello"))
|
||||
assertNull(PreviewMetadata.formatExifDateTime(""))
|
||||
assertNull(PreviewMetadata.formatExifDateTime(null))
|
||||
assertNull(PreviewMetadata.formatExifDateTime("2019:07:04 20:31:05+08:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an invalid exif date leaves the whole item out`() {
|
||||
val items = PreviewMetadata.imageItems(
|
||||
mime = "image/jpeg",
|
||||
capturedAt = "0000:00:00 00:00:00",
|
||||
)
|
||||
|
||||
assertEquals(listOf("image/jpeg"), items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exif time is read as local wall clock with no timezone conversion`() {
|
||||
// 同一串数字在任何时区下都得原样出来——EXIF 里根本没有偏移量字段,换算就是凭空错几小时。
|
||||
assertEquals("2019年7月4日 20:31", PreviewMetadata.formatExifDateTime("2019:07:04 20:31:05"))
|
||||
}
|
||||
|
||||
// —— 拼接 ——
|
||||
|
||||
@Test
|
||||
fun `items are joined with a middle dot`() {
|
||||
assertEquals("a · b · c", PreviewMetadata.join(listOf("a", "b", "c")))
|
||||
assertEquals("", PreviewMetadata.join(emptyList()))
|
||||
}
|
||||
|
||||
// —— 走一遍真实链路:DTO → 芯片 → 这一行 ——
|
||||
|
||||
@Test
|
||||
fun `the chip model feeds the two server facts without re-formatting them`() {
|
||||
val chip = DocumentChips.of(
|
||||
DocumentRefDto(
|
||||
name = "0123456789abcdef0123456789abcdef.txt",
|
||||
mime = "text/plain",
|
||||
size = 12_345,
|
||||
chars = 8_000,
|
||||
truncated = true,
|
||||
status = DocumentStatus.EXTRACTED,
|
||||
),
|
||||
)
|
||||
val items = PreviewMetadata.textItems(
|
||||
slice = DocumentPreview.slice("a\nb".toByteArray()),
|
||||
serverCharsLabel = chip.charsLabel,
|
||||
contextTruncated = chip.truncatedLabel != null,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("2 行", "8,000 字(服务端注入口径)", "UTF-8(本端解码口径)", "正文被截断(进上下文的部分)"),
|
||||
items,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a draft attachment only has the local half`() {
|
||||
val chip = DocumentChips.of(
|
||||
DraftDocument(uri = "content://x/y.txt", displayName = "notes.txt", sizeBytes = 1024),
|
||||
)
|
||||
val items = PreviewMetadata.textItems(
|
||||
slice = DocumentPreview.slice("hello\n".toByteArray()),
|
||||
serverCharsLabel = chip.charsLabel,
|
||||
contextTruncated = chip.truncatedLabel != null,
|
||||
)
|
||||
|
||||
assertEquals(listOf("1 行", "6 字(本端预览内容)", "UTF-8(本端解码口径)"), items)
|
||||
}
|
||||
|
||||
private fun textItems(
|
||||
slice: PreviewSlice,
|
||||
serverCharsLabel: String? = null,
|
||||
contextTruncated: Boolean = false,
|
||||
) = PreviewMetadata.textItems(slice, serverCharsLabel, contextTruncated)
|
||||
|
||||
private fun sliceOf(text: String): PreviewSlice = DocumentPreview.slice(text.toByteArray())
|
||||
|
||||
private fun lines(count: Int): String = (1..count).joinToString("\n") { "line $it" } + "\n"
|
||||
|
||||
private fun grouped(value: Int): String = String.format(java.util.Locale.ROOT, "%,d", value)
|
||||
}
|
||||
Reference in new issue
Block a user