feat(preview): 预览内容下方加一行格式元数据(行数 / 编码 / 分辨率 / EXIF 拍摄日期)

主人要的是「在预览界面下方拿小字放一点简要的格式元数据」。组装全收进一个纯函数
(`PreviewMetadata`,36 档穷举单测),两个预览弹层只负责取证据 + 画一行
(`footnote2` + `onSurfaceVariantSummary`,与项目里 footer / 角标同一套)。

- **文本 / Markdown**:行数(从正文重算;本端被截断时报「显示 N 行 / 共 M 行」,只说
  「显示 N 行」会让人以为文件就这么长)· 字符数(服务端 `chars` 在时报它并写明
  「服务端注入口径」,否则报本端码点数并写明「本端预览内容」——两个数不是一回事)·
  `UTF-8(本端解码口径)`(**只敢写本端口径**:客户端拿不到文件的真实编码,裸写一个
  UTF-8 等于替文件下结论)· 服务端 `truncated` 时「正文被截断(进上下文的部分)」。
- **图片**:格式(复用 `ImageOpenTypes.openType`,退化成「图片通配」时**整项不报**)·
  分辨率(`BitmapFactory.Options(inJustDecodeBounds = true)`,不整图解码)· 体积 ·
  「拍摄于 …」(`android.media.ExifInterface(InputStream)`,系统 API,**不引新依赖**;
  日期严格校验,`0000:00:00 00:00:00` 这类占位符判非法 ⇒ 整项缺席)。
- **非文本(PDF 这类)**:只有服务端字节嗅探所得的 mime。体积 / 服务端状态 / 「读不到」的
  原因原话各自已经在芯片行与正文区那句人话里,**不在这里重复**——宁可少一项,不要一句废话。
- **拿不到的项一律缺席**:不留占位符、不写「未知」、不写 `--`;一项证据都没有就整行不画。

`devtools/fake-backend` 补一个 `image_file` 灌图参数:服务端库里没有任何带 `image` 键的
历史消息可灌,而相册选择器会把选中项**转码**、把 EXIF 丢掉(实测:41 KB 带 EXIF 的 JPEG
经它拿到手变成 23 KB 且没有 APP1),没有它「拍摄日期」这一项在设备上根本验不到。

验收:`assembleDebug` 通过;`testDebugUnitTest` **127 suites / 1600 tests / 0 failures /
0 errors**(基线 126 / 1564,新增 36 条);真机(emulator-5554, API 37)实测:文本那条看到
「5 行 · 70 字(本端预览内容) · UTF-8(本端解码口径)」,图片那条看到
「image/jpeg · 1280 × 720 · 23.0 KB」(该源图不含 EXIF,所以「拍摄于」这一项按设计缺席)。
This commit is contained in:
AskaEth committed 2026-10-07 19:07:00 +08:00
1 parent 11602c0138
commit e962489520
7 files changed
+830 -16

No files matched your search

+33 -1
View File
@@ -357,6 +357,38 @@ curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&dur
> 比真后端**更容易**;真后端上「实时到达的语音先按普通文本显示、等下一次 REST 拉取才升级」那一段,
> 本工具验不到(它也不会复现「回放把已升级的气泡打回文本」那个反向效果)。
### 8.2 图片夹具(灌一条带图的历史消息:`image_file`)
**它解决什么问题**:要验「点开一张图 → 全屏预览」,手里就得有一条**带 `image` 键**的历史消息;
而预览里那些从**字节**现读的东西(分辨率、EXIF 拍摄日期、格式)必须来自**原样的字节**。
相册选择器(`PickVisualMedia`)会把选中项**转码**一遍,转码后的副本**没有 EXIF**(实测:一张带
`DateTimeOriginal` 的 41 KB JPEG,经它拿到手变成 23 KB 且不含 APP1)——用它验不了拍摄日期这一项。
本工具不代取外链、也不起静态文件服务,所以给的是 `data:` URI:客户端
(`ImageSource.Inline` → `ImageDataUri.bytes`)解出来的就是文件**逐字节相同**的那份内容。
`POST /__control/messages` 多一个参数(其余不变):
| 参数 | 取值 | 说明 |
|---|---|---|
| `image_file` | 本地图片路径,扩展名限 `.jpg` / `.jpeg` / `.png` / `.gif` / `.webp` / `.bmp` | 读成 base64 `data:` URI 打进这条消息的 `image` 键。扩展名不在白名单 ⇒ `400`;文件为空 ⇒ `400`(**不**退回 `application/octet-stream`:错的类型会让场景看起来和实际不是一回事) |
```bash
B=http://127.0.0.1:18080
# 灌一条带图的消息(role=user 才长得像「自己发的一张图」)
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=image%20probe&image_file=D:/tmp/w70/with_exif.jpg"
```
灌完之后**重进聊天页**,那条消息就是一个图片气泡;点它即进 `ImagePreviewDialog`。
> ⚠️ `data:` 只适合**小图**:整张图的 base64 会随这条消息进每一次历史响应(本工具没有缩略图链路)。
> 40 KB 的图没问题,别拿 20 MB 的来灌。
>
> ⚠️ 若 App 本机已经有一份**本地历史**(此前连过真后端),新灌的消息可能与它并存 / 不被立刻看到:
> 要让「服务端历史」成为唯一真相,得清 App 数据重登(`adb shell pm clear top.yeij.cyrene`)——
> 这一步会连登录态一起清掉,动手前先想清楚。
`/api/v1/**`(一律包信封:成功 `{data}` / 失败 `{error:{code,message}}`,`ApiEnvelope.kt:8`):
| 端点 | 备注 |
@@ -380,7 +412,7 @@ curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&dur
| 端点 | 作用 |
|---|---|
| `POST /__control/messages?session=&count=&role=&text=&prefix=&platform=&voice=&duration=` | 免费灌 N 条(`text` 支持 `{n}` 占位);`voice=` 灌一条**语音消息**(见 8.1) |
| `POST /__control/messages?session=&count=&role=&text=&prefix=&platform=&voice=&duration=&image_file=` | 免费灌 N 条(`text` 支持 `{n}` 占位);`voice=` 灌一条**语音消息**(见 8.1);`image_file=` 灌一条**带图的消息**(见 8.2) |
| `GET /__control/message?session=&index=` | 取第 n 条(1 基),脚本里用来**精确**拿游标 |
| `POST /__control/busy` / `idle` / `interrupted` | 下发 `session_busy` / `session_idle` / `session_interrupted` |
| `POST /__control/reply?session=&text=&chunks=&frame=&persist=` | 下发一轮助手回复(`frame=default\|chunk\|structured`) |