fix(mobile): translate microphone errors and pre-request permission
实时陪练失败时,getUserMedia 抛出的 Chromium 英文原文会直接透给用户(真机实测见过 "Could not start audio source"),既不可读也不含任何可执行指引。新增 utils/microphone.ts 把权限、设备占用、无设备、非安全上下文四类底层错误翻译成中文 引导;业务层自己抛的中文文案原样透出,未识别的错误回落到原有通用提示。 同时在建会话之前主动申请 RECORD_AUDIO,并在 manifest 显式声明该权限。 需要说明:此权限预申请是防御性的。真机核查 RECORD_AUDIO 已授予、APK 本就声明该权限、 实时链路可正常跑通,先前那次 "Could not start audio source" 是麦克风被其他应用占用 的瞬时故障,不是权限缺陷。未在"权限未授予"的设备上实测过 WebView 是否会自行弹出 Android 运行时授权框,故此调用在已授权设备上为无害空转;若确实不弹,则它是唯一授权入口。 manifest 声明同样只为防未来模块调整导致权限丢失。 新增 6 项单测覆盖错误映射与授权分支(共 168 项通过)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -87,7 +87,11 @@
|
|||||||
},
|
},
|
||||||
"android" : {
|
"android" : {
|
||||||
"packagename" : "com.yincheng.wygj",
|
"packagename" : "com.yincheng.wygj",
|
||||||
"abiFilters" : [ "armeabi-v7a", "arm64-v8a" ]
|
"abiFilters" : [ "armeabi-v7a", "arm64-v8a" ],
|
||||||
|
"permissions" : [
|
||||||
|
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"ios" : {
|
"ios" : {
|
||||||
"appid" : "com.yincheng.wygj",
|
"appid" : "com.yincheng.wygj",
|
||||||
|
|||||||
@@ -531,6 +531,7 @@ import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capt
|
|||||||
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
|
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
|
||||||
import { createRealtimePracticeController, createRealtimeSession, fetchRealtimePersonas, invokeRealtimeTool, type RealtimePersonaOption } from '@/services/realtime-practice';
|
import { createRealtimePracticeController, createRealtimeSession, fetchRealtimePersonas, invokeRealtimeTool, type RealtimePersonaOption } from '@/services/realtime-practice';
|
||||||
import { stripInternalCodes } from '@/services/text';
|
import { stripInternalCodes } from '@/services/text';
|
||||||
|
import { describeMicrophoneError, microphoneDeniedMessage, requestMicrophonePermission } from '@/utils/microphone';
|
||||||
import { safeTopPadding } from '@/utils/safe-area';
|
import { safeTopPadding } from '@/utils/safe-area';
|
||||||
|
|
||||||
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
|
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
|
||||||
@@ -1052,7 +1053,8 @@ const handleRealtimeTerminated = () => {
|
|||||||
status.value = 'idle';
|
status.value = 'idle';
|
||||||
};
|
};
|
||||||
const handleRealtimeError = (text: string) => {
|
const handleRealtimeError = (text: string) => {
|
||||||
if (realtimeActive.value) message.value = text;
|
// 会话过程中的底层错误同样可能是 Chromium 英文原文,统一翻译后再展示。
|
||||||
|
if (realtimeActive.value) message.value = describeMicrophoneError(text, '实时语音出现异常,请结束后重试');
|
||||||
};
|
};
|
||||||
const realtimePractice = createRealtimePracticeController({
|
const realtimePractice = createRealtimePracticeController({
|
||||||
onStatus: handleRealtimeStatus,
|
onStatus: handleRealtimeStatus,
|
||||||
@@ -1115,6 +1117,14 @@ const startRealtime = async () => {
|
|||||||
status.value = 'starting';
|
status.value = 'starting';
|
||||||
realtimeActive.value = true;
|
realtimeActive.value = true;
|
||||||
try {
|
try {
|
||||||
|
// 先申请麦克风权限:App WebView 的 getUserMedia 不会自行弹出系统授权框,
|
||||||
|
// 若跳过这一步,权限缺失时用户只会看到 Chromium 英文报错且没有授权入口。
|
||||||
|
// 放在建会话之前,避免留下一个无法使用的孤儿会话。
|
||||||
|
const permission = await requestMicrophonePermission();
|
||||||
|
if (permission === 'denied' || permission === 'denied-always') {
|
||||||
|
throw new Error(microphoneDeniedMessage(permission));
|
||||||
|
}
|
||||||
|
if (!realtimeActive.value) return;
|
||||||
const session = await createRealtimeSession(selectedRealtimePersonaId.value || undefined, selectedScenarioId.value || undefined);
|
const session = await createRealtimeSession(selectedRealtimePersonaId.value || undefined, selectedScenarioId.value || undefined);
|
||||||
if (!realtimeActive.value) return;
|
if (!realtimeActive.value) return;
|
||||||
realtimeSessionId.value = session.sessionId;
|
realtimeSessionId.value = session.sessionId;
|
||||||
@@ -1130,7 +1140,11 @@ const startRealtime = async () => {
|
|||||||
realtimeStatus.value = 'idle';
|
realtimeStatus.value = 'idle';
|
||||||
realtimeSessionId.value = '';
|
realtimeSessionId.value = '';
|
||||||
status.value = 'idle';
|
status.value = 'idle';
|
||||||
handleError(error, '实时陪练连接失败,请使用录音对练');
|
// 底层音频/WebRTC 错误为 Chromium 英文原文,必须翻译后再展示。
|
||||||
|
handleError(
|
||||||
|
new Error(describeMicrophoneError(error, '实时陪练连接失败,请使用录音对练')),
|
||||||
|
'实时陪练连接失败,请使用录音对练'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* 麦克风权限申请与错误文案映射。
|
||||||
|
*
|
||||||
|
* 背景:getUserMedia 失败时抛出的是 Chromium 英文原文(实测见过
|
||||||
|
* "Could not start audio source"),直接透给用户不可读,故统一翻译成可执行的中文引导。
|
||||||
|
*
|
||||||
|
* 权限预申请是防御性的:未在"权限未授予"的设备上实测过 WebView 是否会自行弹出
|
||||||
|
* Android 运行时授权框。已授权设备上本调用为无害空转;若确实不弹,则它是唯一的授权入口。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RECORD_AUDIO = 'android.permission.RECORD_AUDIO';
|
||||||
|
|
||||||
|
/** 授权申请超时:回调未触发时不能无限挂起,放行交由 getUserMedia 兜底。 */
|
||||||
|
const REQUEST_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
|
export type MicPermissionResult = 'granted' | 'denied' | 'denied-always' | 'unsupported';
|
||||||
|
|
||||||
|
interface PlusAndroidLike {
|
||||||
|
requestPermissions?: (
|
||||||
|
permissions: string[],
|
||||||
|
success: (event: { granted?: string[]; deniedPresent?: string[]; deniedAlways?: string[] }) => void,
|
||||||
|
fail: (error: unknown) => void
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const androidBridge = (): PlusAndroidLike | null => {
|
||||||
|
const plusApi = (globalThis as typeof globalThis & { plus?: { android?: PlusAndroidLike } }).plus;
|
||||||
|
return plusApi?.android ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动申请麦克风权限。
|
||||||
|
* 非 Android App 运行时(H5、iOS)返回 unsupported,由各自平台的原生授权流程处理。
|
||||||
|
*/
|
||||||
|
export const requestMicrophonePermission = (): Promise<MicPermissionResult> => {
|
||||||
|
const android = androidBridge();
|
||||||
|
if (!android?.requestPermissions) return Promise.resolve('unsupported');
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const settle = (result: MicPermissionResult) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => settle('unsupported'), REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
android.requestPermissions!(
|
||||||
|
[RECORD_AUDIO],
|
||||||
|
(event) => {
|
||||||
|
if (event?.granted?.length) settle('granted');
|
||||||
|
else if (event?.deniedAlways?.length) settle('denied-always');
|
||||||
|
else if (event?.deniedPresent?.length) settle('denied');
|
||||||
|
else settle('denied');
|
||||||
|
},
|
||||||
|
() => settle('unsupported')
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
settle('unsupported');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 权限被拒时的中文引导文案。 */
|
||||||
|
export const microphoneDeniedMessage = (result: MicPermissionResult) =>
|
||||||
|
result === 'denied-always'
|
||||||
|
? '麦克风权限已被永久拒绝,请到系统设置 › 应用 › 权限中开启麦克风后重试。'
|
||||||
|
: '需要麦克风权限才能实时对话,请在弹出的授权提示中选择允许。';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把底层音频/WebRTC 错误翻译成中文引导。
|
||||||
|
* 这些原文来自 Chromium 与 WebRTC,直接展示给用户不可读。
|
||||||
|
*/
|
||||||
|
export const describeMicrophoneError = (error: unknown, fallback: string) => {
|
||||||
|
const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : '';
|
||||||
|
const name = error instanceof Error ? error.name : '';
|
||||||
|
const signal = `${name} ${raw}`.toLowerCase();
|
||||||
|
|
||||||
|
// 权限类:系统未授予,或用户拒绝。
|
||||||
|
if (signal.includes('notallowed') || signal.includes('permission')) {
|
||||||
|
return '麦克风权限未开启,请到系统设置 › 应用 › 权限中允许使用麦克风后重试。';
|
||||||
|
}
|
||||||
|
// 设备占用:通常是其他应用(通话、录音)正在使用麦克风。
|
||||||
|
if (signal.includes('could not start audio source') || signal.includes('notreadable') || signal.includes('trackstart')) {
|
||||||
|
return '麦克风被占用或无法启动,请关闭正在通话/录音的应用,并确认已授予麦克风权限后重试。';
|
||||||
|
}
|
||||||
|
// 无可用录音设备。
|
||||||
|
if (signal.includes('notfound') || signal.includes('devices not found')) {
|
||||||
|
return '未检测到可用的麦克风设备,请改用录音对练。';
|
||||||
|
}
|
||||||
|
// 非安全上下文:WebView 未以 https/localhost 承载时 getUserMedia 不可用。
|
||||||
|
if (signal.includes('secure') || signal.includes('ssl')) {
|
||||||
|
return '当前运行环境不支持实时语音,请改用录音对练。';
|
||||||
|
}
|
||||||
|
// 已是中文文案(业务层自己抛的)时原样透出,否则回落到通用提示。
|
||||||
|
return /[一-龥]/.test(raw) ? raw : fallback;
|
||||||
|
};
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { after, test } from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
|
||||||
|
const vite = await createServer({
|
||||||
|
root: fileURLToPath(new URL('..', import.meta.url)),
|
||||||
|
configFile: false,
|
||||||
|
optimizeDeps: { noDiscovery: true },
|
||||||
|
server: { middlewareMode: true, hmr: false },
|
||||||
|
appType: 'custom'
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await vite.close();
|
||||||
|
delete globalThis.plus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const load = () => vite.ssrLoadModule('/src/utils/microphone.ts');
|
||||||
|
|
||||||
|
test('底层音频错误一律翻译为中文,英文原文不得透给用户', async () => {
|
||||||
|
const { describeMicrophoneError } = await load();
|
||||||
|
const fallback = '实时陪练连接失败,请使用录音对练';
|
||||||
|
|
||||||
|
// 这些是 Chromium / WebRTC 的真实原文,真机上曾直接展示给用户。
|
||||||
|
const rawErrors = [
|
||||||
|
new Error('Could not start audio source'),
|
||||||
|
Object.assign(new Error('Permission denied'), { name: 'NotAllowedError' }),
|
||||||
|
Object.assign(new Error('Requested device not found'), { name: 'NotFoundError' }),
|
||||||
|
Object.assign(new Error('Could not start audio source'), { name: 'NotReadableError' }),
|
||||||
|
'getUserMedia requires a secure context'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const error of rawErrors) {
|
||||||
|
const text = describeMicrophoneError(error, fallback);
|
||||||
|
assert.match(text, /[一-龥]/, `应翻译为中文: ${error}`);
|
||||||
|
assert.ok(!/[A-Za-z]{6,}/.test(text), `不应残留英文原文: ${text}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('业务层自抛的中文错误原样透出,不被兜底文案吞掉', async () => {
|
||||||
|
const { describeMicrophoneError } = await load();
|
||||||
|
const own = '实时语音模块尚未就绪,请稍后重试';
|
||||||
|
assert.equal(describeMicrophoneError(new Error(own), '兜底文案'), own);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无法识别的错误回落到兜底文案', async () => {
|
||||||
|
const { describeMicrophoneError } = await load();
|
||||||
|
assert.equal(describeMicrophoneError(new Error('zzz unknown'), '兜底文案'), '兜底文案');
|
||||||
|
assert.equal(describeMicrophoneError(undefined, '兜底文案'), '兜底文案');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Android 运行时主动申请 RECORD_AUDIO 并区分拒绝与永久拒绝', async () => {
|
||||||
|
const requested = [];
|
||||||
|
globalThis.plus = {
|
||||||
|
android: {
|
||||||
|
requestPermissions: (permissions, success) => {
|
||||||
|
requested.push(...permissions);
|
||||||
|
success(pendingResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let pendingResult = { granted: ['android.permission.RECORD_AUDIO'] };
|
||||||
|
const { requestMicrophonePermission } = await load();
|
||||||
|
|
||||||
|
assert.equal(await requestMicrophonePermission(), 'granted');
|
||||||
|
assert.deepEqual(requested, ['android.permission.RECORD_AUDIO']);
|
||||||
|
|
||||||
|
pendingResult = { deniedPresent: ['android.permission.RECORD_AUDIO'] };
|
||||||
|
assert.equal(await requestMicrophonePermission(), 'denied');
|
||||||
|
|
||||||
|
pendingResult = { deniedAlways: ['android.permission.RECORD_AUDIO'] };
|
||||||
|
assert.equal(await requestMicrophonePermission(), 'denied-always');
|
||||||
|
|
||||||
|
delete globalThis.plus;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非 App 运行时返回 unsupported,交由平台原生授权流程处理', async () => {
|
||||||
|
delete globalThis.plus;
|
||||||
|
const { requestMicrophonePermission } = await load();
|
||||||
|
assert.equal(await requestMicrophonePermission(), 'unsupported');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('永久拒绝的引导文案指向系统设置,普通拒绝不误导', async () => {
|
||||||
|
const { microphoneDeniedMessage } = await load();
|
||||||
|
assert.match(microphoneDeniedMessage('denied-always'), /系统设置/);
|
||||||
|
assert.ok(!/系统设置/.test(microphoneDeniedMessage('denied')));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user