[Bug] fix(privacy): 首次语音输入应先完成统一隐私同意,再请求系统麦克风权限
- Dominant language
- TypeScript
- Stars
- 2.7k
- Forks
- 395
- Avg merge
- 21h 48m
- Merged PRs (30d)
- 776
Description
## 问题描述
首次使用语音输入时,桌面端和移动端会直接进入系统麦克风权限请求或语音启动流程,没有先展示统一的产品级隐私说明,也没有按隐私协议版本持久化用户的同意状态。
移动端还会在麦克风按钮 `pressIn` 阶段提前预热音频会话、读取语音 credential,并尝试连接云端 ASR。这些动作发生在系统录音权限请求之前,同样没有隐私同意门控。
- 关联缺陷:BUG-017
- 严重程度:P1
- 关联用例:TC-06-07、TC-5 / 首次使用语音输入隐私提示
## 当前行为
### 桌面端
聊天输入框麦克风按钮、输入框快捷键及全局快捷键最终进入共享语音启动守卫。权限状态未缓存为已授权时,会直接执行:
```ts
navigator.mediaDevices.getUserMedia({ audio: true })
```
当前应用内“需要麦克风权限”弹窗只在权限探测失败后显示,属于权限失败引导,不是首次使用前的隐私确认。
相关代码:
- `apps/desktop/src/renderer/components/new-chat/ChatInput.tsx`
- `apps/desktop/src/renderer/voice-input/useVoiceInput.ts`
- `apps/desktop/src/renderer/voice-input/startGuards.ts`
桌面全局语音浮层还有独立启动入口,不经过 `ChatInput` 的权限弹窗:
- `apps/desktop/src/renderer/voice-input/VoiceInputOverlay.tsx`
### 移动端
已有会话和新建会话页面的语音启动函数都会直接调用:
```ts
requestRecordingPermissionsAsync()
```
相关代码:
- `apps/mobile/app/sessions/[sessionId].tsx`
- `apps/mobile/app/sessions/new.tsx`
两个页面还会在 `onPressIn` 调用:
```ts
prewarmMobileVoiceStart(deviceId)
```
该预热过程可能激活 iOS 音频会话、读取语音 credential、创建 ASR provider 并连接 WebSocket:
- `apps/mobile/src/session/mobileVoicePrewarm.ts`
- `apps/mobile/src/session/mobileRealtimeAudio.ts`
- `apps/mobile/modules/xdt-mobile-realtime-audio/ios/XdtMobileRealtimeAudioModule.swift`
## 期望行为
所有语音输入入口统一执行:
```text
用户主动触发语音输入
→ 检查当前隐私协议版本是否已同意
→ 未同意时展示应用内语音隐私说明
→ 用户明确同意且同意状态持久化成功
→ 请求系统麦克风权限
→ 权限通过后再启动录音、credential 读取和 ASR
```
用户取消或同意状态持久化失败时:
- 不请求系统麦克风权限;
- 不激活音频会话;
- 不读取或同步 voice credential;
- 不连接 ASR/refiner 服务;
- 不创建语音录音 controller;
- 文字输入保持可用。
## 隐私说明要求
首次确认弹窗至少应说明:
1. Cindy 仅在用户主动触发语音输入时使用麦克风;
2. 音频会发送到配置的语音识别服务进行转写;
3. 如果启用语音润色,转写文本可能继续发送给 AI 模型处理;
4. 用户可以取消,取消不影响文字输入;
5. 提供正式隐私政策链接;
6. 提供明确的“同意并继续”和“取消”操作。
> 当前仓库中未发现可复用的正式隐私政策 URL,需要产品/法务提供 canonical HTTPS 地址及协议版本。
## 实现要求
### 版本化同意状态
使用独立的隐私协议版本和同意记录,例如:
```ts
type VoicePrivacyConsent = {
policyVersion: string;
acceptedAt: number;
};
```
仅当保存的 `policyVersion` 与当前协议版本一致时,才视为已同意。
要求:
- 不得只保存 `accepted: true`;
- 不得复用语音数据文件的 schema version 作为隐私协议版本;
- 协议版本更新后必须重新确认;
- 持久化失败时必须 fail-closed,不能继续启动语音。
### 桌面端
共享 consent gate 应覆盖:
- 聊天输入框麦克风按钮;
- 输入框语音快捷键;
- 全局语音快捷键;
- 全局语音浮层。
同意检查必须发生在 `getUserMedia()` 和语音启动守卫之前,不能只在 `ChatInput` 按钮组件上增加弹窗。
可复用现有 `ConfirmDialogProvider` UI,以及 `VoiceInputDataStore` 的原子写盘和 IPC 广播能力;但版本化 consent 应使用独立数据字段。
### 移动端
已有会话页和新建会话页应复用同一个 consent store 和确认组件。
未经同意或 consent 尚未完成读取时:
- `onPressIn` 不得调用 `prewarmMobileVoiceStart()`;
- 不得读取 credential;
- 不得创建或连接 ASR provider。
完成同意后才调用 `requestRecordingPermissionsAsync()`。同一版本已同意的后续使用,可以恢复 `pressIn` 预热。
Consent 应是当前录音设备的本地状态:
- 桌面端同意状态保存在桌面端;
- 手机端同意状态保存在手机端;
- 不通过 device-link 自动继承另一设备的同意状态;
- 不按远程 host 分别保存。
## Android 说明
当前 `xdt-mobile-realtime-audio` 只实现了 Apple 平台,Android 会返回“不支持实时语音输入”。
本 Issue 暂不要求实现 Android 原生录音,但需要:
- 保持 Android 不进入无意义的隐私确认和录音权限流程;
- 在后续 Android 原生录音实现后补充完整的 consent → `RECORD_AUDIO` → 录音验收;
- 设计的 consent 模型应支持未来 Android 接入。
## 验收标准
- [ ] 桌面首次点击麦克风按钮时,应用隐私弹窗先于 OS 麦克风权限弹窗出现。
- [ ] 桌面输入框语音快捷键经过同一 consent gate。
- [ ] 桌面全局快捷键及全局语音浮层经过同一 consent gate。
- [ ] 移动端已有会话和新建会话页面共用同一 consent gate。
- [ ] 移动端短按、长按入口均不能绕过 consent。
- [ ] 未同意或 consent 尚未完成读取时,移动端 `pressIn` 不执行语音预热。
- [ ] 用户取消后不请求 OS 权限、不激活音频会话、不读取 credential、不连接 ASR。
- [ ] 用户同意且持久化成功后,才请求 OS 麦克风权限。
- [ ] consent 持久化失败时阻止后续启动,并展示可理解的错误。
- [ ] 同一隐私协议版本接受后不重复弹窗。
- [ ] 隐私协议版本升级后重新询问,即使 OS 麦克风权限已经授权。
- [ ] 弹窗包含正式隐私政策链接,桌面端和移动端均可正常打开。
- [ ] consent 是采集设备的本地状态,不通过 device-link 自动继承。
- [ ] 不破坏桌面现有权限缓存快速路径。
- [ ] 同意后的移动端后续使用可以恢复 `pressIn` 预热性能。
- [ ] Android 当前保持明确的“不支持”行为,并记录原生实现后的补充验收。
## 建议测试
### TC-PRIV-VOICE-001:桌面首次启动
1. 清除本地 consent,将 OS 麦克风权限重置为未决定。
2. 点击聊天输入框麦克风按钮。
3. 验证先显示应用隐私弹窗。
4. 验证弹窗确认前没有调用 `getUserMedia` 或语音启动 IPC。
5. 点击“同意并继续”。
6. 验证 consent 持久化成功后才显示 OS 权限弹窗。
7. 系统授权后验证语音正常启动。
### TC-PRIV-VOICE-002:取消同意
1. 清除 consent。
2. 触发语音输入并点击取消。
3. 验证不请求 OS 权限、不启动录音、不连接 ASR。
4. 验证文字输入正常。
### TC-PRIV-VOICE-003:桌面其他入口
分别使用输入框快捷键、全局快捷键和全局语音浮层触发语音,验证均复用同一 consent gate。
### TC-PRIV-VOICE-004:移动端首次短按
1. 清除 consent。
2. 触发麦克风按钮 `pressIn`。
3. 验证没有调用 `prewarmMobileVoiceStart()`。
4. 完成点击后验证先显示隐私弹窗。
5. 验证同意前没有调用 `requestRecordingPermissionsAsync()`。
6. 同意后验证才请求系统权限并启动录音。
### TC-PRIV-VOICE-005:移动端长按
验证长按不能绕过 consent;取消后松手不会错误停止一个未启动的 controller。
### TC-PRIV-VOICE-006:协议版本升级
1. 保存旧协议版本 consent。
2. 升级当前协议版本。
3. 触发语音输入。
4. 验证重新展示隐私弹窗。
5. 取消后验证即使 OS 已授权也不会启动录音。
### TC-PRIV-VOICE-007:持久化失败
模拟桌面写盘或移动端 AsyncStorage 写入失败,验证:
- 不请求 OS 权限;
- 不启动录音或 ASR;
- 不在内存中错误标记为已同意;
- 再次进入时仍需要确认。
### TC-PRIV-VOICE-008:已同意快速路径
同版本已同意时验证:
- 不重复展示应用隐私弹窗;
- 桌面继续使用权限正缓存;
- 移动端 `pressIn` 可以正常预热;
- 录音启动性能没有明显回退。
## 远程连接与手机版
- SSH 远程工作区:本功能不读取工作区文件,consent 保存在采集麦克风的本地设备,不依赖远程文件系统。
- Device-link:手机和桌面分别维护本地 consent,不能通过 device-link 自动互相授权。
- 手机版:已有会话和新建会话两个 composer 必须一并适配。
- Android:当前原生录音未实现,后续实现时补充跟踪验收。
## 相关 Issue
- https://github.com/makecindy/cindy/issues/15
- https://github.com/makecindy/cindy/issues/19
- https://github.com/makecindy/cindy/issues/16
- `旧版内部仓库 Issue#336`(链接已移除)
- `旧版内部仓库 Issue#201`(链接已移除)
Contributor guide
Research direction
Start with the listed desktop entry points in ChatInput.tsx, useVoiceInput.ts, startGuards.ts, and VoiceInputOverlay.tsx, then inspect the mobile session pages and mobileVoicePrewarm.ts. Trace the existing permission and prewarm paths before reviewing TC-PRIV-VOICE-001 through TC-PRIV-VOICE-008. Done means every listed voice entry uses versioned local consent before permission or audio/ASR work, with cancellation and persistence failures remaining fail-closed.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- swift, typescript
- Domain
- desktop, mobile, security
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100