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:
1 parent
11602c0138
commit
e962489520
7 files changed
+830
-16
No files matched your search
@@ -187,6 +187,42 @@ def voice_fixture(kind: str, duration_ms: int = VOICE_FIXTURE_DURATION_MS):
|
||||
VOICE_WAV = make_voice_wav()
|
||||
|
||||
|
||||
IMAGE_MIME_BY_EXTENSION = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
|
||||
|
||||
def image_fixture(path: str) -> str:
|
||||
"""本地图片文件 → base64 `data:` URI(`/__control/messages` 的 `image_file` 用它灌一条带图的消息)。
|
||||
|
||||
mime 按扩展名给,白名单与客户端 upload / 附件那一套同源(png / jpeg / gif / webp / bmp);
|
||||
认不出的扩展名**直接报错**而不是编一个 `application/octet-stream`——客户端在字节那一步还会
|
||||
自己嗅探(`ImageFormats.sniff`),这里给错只会让场景看起来和实际不是一回事。
|
||||
|
||||
为什么给 `data:` 而不是 URL:本工具**不代取外链**、也不起静态文件服务。`data:` 让客户端
|
||||
(`ImageSource.Inline` → `ImageDataUri.bytes`)拿到的是**原样的字节**——预览里那些从字节里
|
||||
现读的东西(分辨率、EXIF 拍摄日期)因此与源文件逐字节一致,不会被中间商转码掉。
|
||||
"""
|
||||
extension = os.path.splitext(path)[1].lower()
|
||||
mime = IMAGE_MIME_BY_EXTENSION.get(extension)
|
||||
if mime is None:
|
||||
raise ValueError(
|
||||
"unknown image extension {!r} (expected one of {})".format(
|
||||
extension, ", ".join(sorted(IMAGE_MIME_BY_EXTENSION))
|
||||
)
|
||||
)
|
||||
with open(path, "rb") as handle:
|
||||
raw = handle.read()
|
||||
if not raw:
|
||||
raise ValueError("empty image file: {}".format(path))
|
||||
return "data:{};base64,{}".format(mime, base64.b64encode(raw).decode("ascii"))
|
||||
|
||||
|
||||
def iso_now() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||
|
||||
@@ -350,12 +386,16 @@ class Store:
|
||||
created_at=None,
|
||||
message_id=None,
|
||||
voice=None,
|
||||
image=None,
|
||||
) -> dict:
|
||||
"""HistoryMessageDto(MessageDto.kt:13)。可选键为空时**整键省略**,与后端 omitempty 一致。
|
||||
|
||||
[message_id] 非空 = 用挂起时刻先占好的号(见 [hold_message]),不再动 `message_seq`。
|
||||
[voice] 非空 = 这条是**语音消息**(契约 v0.79 §4.4:🎤 判据 = `voice` 键存在)——
|
||||
只有控制端点能造出来,真后端的入站语音副本链本工具不模拟。
|
||||
[image] 非空 = 这条带一张图(契约 §1.1:base64 `data:` URI 或 http(s) URL,两种形状客户端
|
||||
都认,判据见 `ImageSource.of`)。本工具**不代取 URL**,所以只由控制端点灌 `data:` URI
|
||||
(见 `/__control/messages` 的 `image_file`)——与 `voice` 同一性质:造场景用,不是后端行为。
|
||||
"""
|
||||
with self.lock:
|
||||
session = self.sessions.get(session_id)
|
||||
@@ -385,6 +425,8 @@ class Store:
|
||||
message["documents"] = documents
|
||||
if voice:
|
||||
message["voice"] = voice
|
||||
if image:
|
||||
message["image"] = image
|
||||
session.messages.append(message)
|
||||
return message
|
||||
|
||||
@@ -1511,11 +1553,17 @@ class Handler(BaseHTTPRequestHandler):
|
||||
template = self._param(query, "text", None)
|
||||
platform = self._param(query, "platform", None)
|
||||
voice_kind = self._param(query, "voice", None)
|
||||
image_file = self._param(query, "image_file", None)
|
||||
try:
|
||||
voice = voice_fixture(voice_kind, self._int_param(query, "duration", VOICE_FIXTURE_DURATION_MS))
|
||||
except KeyError:
|
||||
self._fail(400, "bad_request", "voice must be one of transcribed/empty/failed/nocopy/none")
|
||||
return
|
||||
try:
|
||||
image = image_fixture(image_file) if image_file else None
|
||||
except (OSError, ValueError) as error:
|
||||
self._fail(400, "bad_request", "image_file unusable: {}".format(error))
|
||||
return
|
||||
if session_id not in self.store.sessions:
|
||||
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
|
||||
return
|
||||
@@ -1528,6 +1576,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
content,
|
||||
platform=platform,
|
||||
voice=voice,
|
||||
image=image,
|
||||
)
|
||||
first_id = first_id or message["id"]
|
||||
last_id = message["id"]
|
||||
|
||||
Reference in new issue
Block a user