From 2fbf24a6752a8c10491be68217851bbb8470a36b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 11:13:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(voice):=20=E6=8E=A5=E6=94=B6=E4=BE=A7?= =?UTF-8?q?=E8=AF=AD=E9=9F=B3=E6=B6=88=E6=81=AF=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=20+=20=E6=92=AD=E6=94=BE=EF=BC=88W62=20/=20?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=20v0.79=20=C2=A74.4=20=E4=B8=8E=20=C2=A71.2?= =?UTF-8?q?=20U19=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 别的客户端(QQ / 微信)发来的语音,现在能正确显示并播放。只做接收侧,不做本端录音/发送。 契约口径(逐条落地,不自己发明键名): - **判据 = `voice` 键存在**:绝不从 `content` 正文里认媒体类型(`[语音]` 占位标记自 v0.67 起不再 下发,正文里也可能有用户自己打的同样字样); - **按 `status` 分支**(`transcribed` / `empty` / `failed` 三态),正文一律照常显示、一个字不改; - **`name` / `mime` / `size` / `url` 四者同生共死** ⇒ 凑不齐就**不画播放控件**(不给必然失败的按钮); - **`durationMs` 缺席就不显示时长**(不反算、不显示 `0:00` 冒充); - **不在 WS 实时帧上等 `voice`**(覆盖率:只有四个 REST 读取面填充)—— 实时到达的先按普通文本显示, 由既有的「按 id 合并、新到的胜出」在下一次拉取时自动升级成语音气泡(新增单测钉住这条依赖)。 改了什么: - `VoiceDto`(7 个键逐字照抄)+ `HistoryMessageDto.voice: VoiceDto? = null`(缺席与 null 同一件事); - `domain/chat/VoiceMessage.kt`:判据与展示换算的**唯一落点**(`isVoiceMessage` / `voiceAudio` / `voiceDurationLabel` / `voiceNote` / `voiceMediaUrl` / `voicePlaybackFailureMessage`); - `domain/chat/VoicePlayback.kt`:播放通道的纯状态机(Idle / Loading / Playing / Failed), 每次转换都按 `messageId` 比对 ⇒ 迟到回调不许改状态; - `ui/chat/VoicePlayer.kt`:`android.media.MediaPlayer`(平台 API,**不引任何新依赖**)驱动那条通道, 三处收干净:换目标先停旧、换会话换实例、组合销毁 `onDispose` 释放; - `ui/chat/VoiceBubble.kt`:`[播放/暂停] 语音 · 时长` + `empty` 那句补充说明 + 失败提示(一行,error 色); 图标用 AAR 里真实存在的 `MiuixIcons.Play` / `Pause`(extended 包,basic 那 7 枚里没有); - `data/api/MediaApi.kt` + `data/repository/VoiceStore.kt`:`GET {当前地址}{voice.url}`(自带的 Bearer + 401 续期走 `ApiClientFactory`),字节落 `cacheDir/media/<存储名>`(复用 `DocumentCache` 的名字判据 / 命中判定 / 原子落盘;**不落 `documents/`**,那是 FileProvider 唯一暴露的根); - `MessageBubble` 渲染语音形态;`isContentlessUserMessage` 加一条「语音消息不算什么都没有」 (否则空转写的语音会被扣上「内容没有被传过来」那顶**假**帽子); - `devtools/fake-backend/`:新增语音夹具(`__control/messages?voice=&duration=` + 真 2 秒 WAV 的 `/api/v1/media/{name}`)—— 生产库里一条带 `voice` 的真消息都没有,设备验收只能靠造。 为什么这么改:`MediaPlayer` 不 `release()` 会一直占着解码器与音频通路,而语音是唯一「会出声」的 消息形态,生命周期必须有一处说清楚(写在 `VoicePlayer` 的 KDoc);播放失败必须可见(按钮回未播 + 一行短提示),一条点了没反应的语音在用户眼里就是坏的。 验收:`assembleDebug` 通过;`testDebugUnitTest` 120 suites / 1458 tests / 0 failures / 0 errors (基线 115 / 1405,新增 5 个套件 53 条)。 --- .../top/yeij/cyrene/app/AppContainer.kt | 30 +++ .../top/yeij/cyrene/data/api/MediaApi.kt | 41 +++ .../yeij/cyrene/data/api/dto/MessageDto.kt | 23 ++ .../top/yeij/cyrene/data/api/dto/VoiceDto.kt | 62 +++++ .../yeij/cyrene/data/repository/VoiceStore.kt | 164 ++++++++++++ .../yeij/cyrene/domain/chat/VoiceMessage.kt | 222 ++++++++++++++++ .../yeij/cyrene/domain/chat/VoicePlayback.kt | 121 +++++++++ .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 21 ++ .../top/yeij/cyrene/ui/chat/MessageBubble.kt | 33 ++- .../top/yeij/cyrene/ui/chat/VoiceBubble.kt | 173 ++++++++++++ .../top/yeij/cyrene/ui/chat/VoicePlayer.kt | 200 ++++++++++++++ .../yeij/cyrene/data/api/dto/VoiceDtoTest.kt | 218 +++++++++++++++ .../data/repository/MediaRepositoryTest.kt | 205 ++++++++++++++ .../data/repository/MessageMergeTest.kt | 29 ++ .../cyrene/data/repository/VoiceStoreTest.kt | 187 +++++++++++++ .../cyrene/domain/chat/VoiceMessageTest.kt | 250 ++++++++++++++++++ .../cyrene/domain/chat/VoicePlaybackTest.kt | 152 +++++++++++ .../cyrene/ui/chat/ContentlessMessageTest.kt | 19 +- devtools/fake-backend/README.md | 63 ++++- devtools/fake-backend/fake_backend.py | 125 ++++++++- 20 files changed, 2330 insertions(+), 8 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/MediaApi.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/dto/VoiceDto.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/repository/VoiceStore.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/chat/VoiceMessage.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/chat/VoicePlayback.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/chat/VoiceBubble.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/chat/VoicePlayer.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/api/dto/VoiceDtoTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/repository/MediaRepositoryTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/repository/VoiceStoreTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/chat/VoiceMessageTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/chat/VoicePlaybackTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt index 73e694f..78d2b94 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt @@ -15,6 +15,7 @@ import top.yeij.cyrene.data.api.AdminApi import top.yeij.cyrene.data.api.ApiClientFactory import top.yeij.cyrene.data.api.ChatApi import top.yeij.cyrene.data.api.DocumentsApi +import top.yeij.cyrene.data.api.MediaApi import top.yeij.cyrene.data.api.MetaApi import top.yeij.cyrene.data.api.PersonaApi import top.yeij.cyrene.data.api.SessionsApi @@ -23,6 +24,7 @@ import top.yeij.cyrene.data.api.dto.SessionSummaryDto import top.yeij.cyrene.data.repository.AdminRepository import top.yeij.cyrene.data.repository.ChatRepository import top.yeij.cyrene.data.repository.DocumentRepository +import top.yeij.cyrene.data.repository.MediaRepository import top.yeij.cyrene.data.repository.MessageRepository import top.yeij.cyrene.data.repository.MetaRepository import top.yeij.cyrene.data.repository.PersonaRepository @@ -186,6 +188,34 @@ class AppContainer(context: Context) { val documentRepository: DocumentRepository get() = DocumentRepository(documentsApi) + private var cachedMediaApi: MediaApi? = null + private var cachedMediaBaseUrl: String? = null + + /** + * 语音副本端点(`GET /api/v1/media/{name}`,契约 §1.2 **U19**);与 [documentsApi] **同一套门**: + * `owner|admin` **必须 Bearer**(与图片读侧「名字即凭据」刻意相反 —— 语音是私密内容)。 + * + * 同样与 [chatApi] 同源(同一 baseUrl、同一 token/401 处理):`voice.url` 是**服务端相对路径**, + * 只有落在**同一台后端**上才取得到那份副本。 + */ + val mediaApi: MediaApi + get() { + val baseUrl = authManager.currentBaseUrl + val cached = cachedMediaApi + if (cached != null && cachedMediaBaseUrl == baseUrl) return cached + val created = ApiClientFactory.create( + baseUrl = baseUrl, + service = MediaApi::class.java, + tokenProvider = authManager::currentToken, + unauthorizedPolicy = authManager.unauthorizedPolicy, + ) + cachedMediaBaseUrl = baseUrl + cachedMediaApi = created + return created + } + + val mediaRepository: MediaRepository get() = MediaRepository(mediaApi) + private var cachedAdminApi: AdminApi? = null private var cachedAdminBaseUrl: String? = null diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/MediaApi.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/MediaApi.kt new file mode 100644 index 0000000..cc14ad3 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/MediaApi.kt @@ -0,0 +1,41 @@ +package top.yeij.cyrene.data.api + +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Streaming +import retrofit2.http.Url + +/** + * 语音副本的字节读取(契约 **v0.79 已实现**,api-client.md §1.2 **U19** + * `GET /api/v1/media/{name}`)。 + * + * ## 鉴权:**必须** Bearer `owner|admin` + * 与 `GET /api/v1/documents/{name}` 同一套门,与图片读侧「128-bit 名即凭据」**刻意相反** + * ——语音是私密内容,那个 URL 不能直接贴进 `