feat(release): close handler and microphone gaps

This commit is contained in:
key
2026-07-30 12:01:45 +08:00
parent 8934f13811
commit c16a3e83d7
30 changed files with 830 additions and 49 deletions
@@ -137,7 +137,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
import type {
AsrResponse,
InterviewFinishResponse,
@@ -501,7 +501,7 @@ onShow(() => {
onHide(() => {
speechPlayback.stop();
if (!isPlatformFileSelectionActive()) cancelQuestionRecording();
if (!isPlatformNativeActivityActive()) cancelQuestionRecording();
});
onUnload(() => {
+2 -1
View File
@@ -177,6 +177,7 @@ import { resetPageScroll } from '@/services/navigation';
import { createPageRequestScope } from '@/services/request-scope';
import { startSpeechCapture, type SpeechCapture, type SpeechCaptureSession } from '@/services/speech-capture';
import { transcribeSpeechCapture } from '@/services/speech';
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
const path = '/pages/user/direct/index';
const pageSize = 20;
@@ -485,7 +486,7 @@ onShow(async () => {
});
onHide(() => {
requests.invalidate();
teardownVoice();
if (!isPlatformNativeActivityActive()) teardownVoice();
});
onUnload(() => {
requests.invalidate();
+2 -2
View File
@@ -503,7 +503,7 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
import ChatComposer from '@/components/chat/ChatComposer.vue';
import RealtimePracticeAppBridge from '@/components/practice/RealtimePracticeAppBridge.vue';
import type {
@@ -1852,7 +1852,7 @@ onHide(() => {
stopRealtime();
invalidateRequests();
speechPlayback.stop();
if (!isPlatformFileSelectionActive()) cancelRecording();
if (!isPlatformNativeActivityActive()) cancelRecording();
});
onUnload(() => {
+3 -3
View File
@@ -231,7 +231,7 @@
import { computed, nextTick, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import ChatComposer from '@/components/chat/ChatComposer.vue';
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { chooseKnowledgeMedia } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation';
@@ -784,7 +784,7 @@ const teardown = (preservePlatformFileSelection = false) => {
activeUploadTask?.abort();
activeUploadTask = null;
speechPlayback.stop();
composerRef.value?.teardown();
if (!preservePlatformFileSelection) composerRef.value?.teardown();
organizing.value = false;
uploading.value = false;
submitting.value = false;
@@ -838,7 +838,7 @@ onShow(() => {
}
});
onHide(() => teardown(isPlatformFileSelectionActive()));
onHide(() => teardown(isPlatformNativeActivityActive()));
onUnload(() => {
teardown();
speechPlayback.destroy();
+5 -3
View File
@@ -424,7 +424,7 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
import type {
AnswerFeedbackVerdict,
BroadcastKnowledgeContext,
@@ -1565,8 +1565,10 @@ onHide(() => {
candidateRefreshGeneration += 1;
cancelPendingBroadcastConsultation();
speechPlayback.stop();
composerRef.value?.teardown();
if (!isPlatformFileSelectionActive()) cancelVoiceTranscription();
if (!isPlatformNativeActivityActive()) {
composerRef.value?.teardown();
cancelVoiceTranscription();
}
cancelPendingMediaSearch();
});
+5 -2
View File
@@ -219,6 +219,7 @@ import { ensureEmployeePosition } from '@/services/position';
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
import { getBroadcastUnreadCount } from '@/services/broadcast';
import { isFileSelectionCancelled } from '@/utils/file-selection';
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
const home = ref<RoleHome | null>(null);
const assignments = ref<PracticeAssignment[]>([]);
@@ -578,8 +579,10 @@ onShow(() => {
});
onHide(() => {
holdGeneration += 1;
cancelHold();
if (!isPlatformNativeActivityActive()) {
holdGeneration += 1;
cancelHold();
}
requests.invalidate();
});
@@ -1,6 +1,9 @@
let activePlatformFileSelections = 0;
let activePlatformPermissionActivities = 0;
export const isPlatformFileSelectionActive = () => activePlatformFileSelections > 0;
export const isPlatformNativeActivityActive = () =>
activePlatformFileSelections > 0 || activePlatformPermissionActivities > 0;
export const withPlatformFileSelection = async <T>(select: () => Promise<T>): Promise<T> => {
activePlatformFileSelections += 1;
@@ -10,3 +13,12 @@ export const withPlatformFileSelection = async <T>(select: () => Promise<T>): Pr
activePlatformFileSelections = Math.max(0, activePlatformFileSelections - 1);
}
};
export const withPlatformPermissionActivity = async <T>(request: () => Promise<T>): Promise<T> => {
activePlatformPermissionActivities += 1;
try {
return await request();
} finally {
activePlatformPermissionActivities = Math.max(0, activePlatformPermissionActivities - 1);
}
};
+55 -6
View File
@@ -8,14 +8,25 @@
* Android 运行时授权框。已授权设备上本调用为无害空转;若确实不弹,则它是唯一的授权入口。
*/
import { withPlatformPermissionActivity } from '../services/file-selection-lifecycle';
const RECORD_AUDIO = 'android.permission.RECORD_AUDIO';
/** 授权申请超时:回调未触发时不能无限挂起,放行交由 getUserMedia 兜底。 */
const REQUEST_TIMEOUT_MS = 15000;
/** Android 权限 Activity 返回后,必须等应用窗口重新获得焦点再启动录音或文件选择。 */
const ACTIVITY_FOCUS_POLL_MS = 50;
const ACTIVITY_FOCUS_TIMEOUT_MS = 1500;
interface AndroidActivityLike {
hasWindowFocus?: () => boolean;
}
export type MicPermissionResult = 'granted' | 'denied' | 'denied-always' | 'unsupported';
interface PlusAndroidLike {
importClass?: (target: unknown) => unknown;
runtimeMainActivity?: () => AndroidActivityLike;
requestPermissions?: (
permissions: string[],
success: (event: { granted?: string[]; deniedPresent?: string[]; deniedAlways?: string[] }) => void,
@@ -28,6 +39,42 @@ const androidBridge = (): PlusAndroidLike | null => {
return plusApi?.android ?? null;
};
const settleAfterPermissionActivity = (
android: PlusAndroidLike,
settle: (result: MicPermissionResult) => void,
result: MicPermissionResult
) => {
let activity: AndroidActivityLike | undefined;
try {
activity = android.runtimeMainActivity?.();
if (activity) android.importClass?.(activity);
} catch {
// 某些旧基座不暴露主 Activity;仍至少跨过当前权限回调任务。
}
if (!activity?.hasWindowFocus) {
setTimeout(() => settle(result), ACTIVITY_FOCUS_POLL_MS);
return;
}
const deadline = Date.now() + ACTIVITY_FOCUS_TIMEOUT_MS;
const poll = () => {
let focused = false;
try {
focused = Boolean(activity?.hasWindowFocus?.());
} catch {
settle(result);
return;
}
if (focused || Date.now() >= deadline) {
settle(result);
return;
}
setTimeout(poll, ACTIVITY_FOCUS_POLL_MS);
};
// 即使回调时已经恢复焦点,也跨过一次事件循环,避免和权限 Activity 的 finish 竞争。
setTimeout(poll, ACTIVITY_FOCUS_POLL_MS);
};
/**
* 主动申请麦克风权限。
* 非 Android App 运行时(H5、iOS)返回 unsupported,由各自平台的原生授权流程处理。
@@ -36,7 +83,7 @@ export const requestMicrophonePermission = (): Promise<MicPermissionResult> => {
const android = androidBridge();
if (!android?.requestPermissions) return Promise.resolve('unsupported');
return new Promise((resolve) => {
return withPlatformPermissionActivity(() => new Promise((resolve) => {
let settled = false;
const settle = (result: MicPermissionResult) => {
if (settled) return;
@@ -50,17 +97,19 @@ export const requestMicrophonePermission = (): Promise<MicPermissionResult> => {
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');
const result: MicPermissionResult = event?.granted?.length
? 'granted'
: event?.deniedAlways?.length
? 'denied-always'
: 'denied';
settleAfterPermissionActivity(android, settle, result);
},
() => settle('unsupported')
);
} catch {
settle('unsupported');
}
});
}));
};
/** 权限被拒时的中文引导文案。 */
+24 -7
View File
@@ -195,6 +195,19 @@ test('Android 文件选择期间保持页面请求有效,并在完成后恢复
assert.equal(fileSelectionLifecycle.isPlatformFileSelectionActive(), false);
});
test('Android 权限 Activity 期间保持原生交互状态,但不冒充文件选择', async () => {
let finishPermission;
const permission = fileSelectionLifecycle.withPlatformPermissionActivity(() =>
new Promise((resolve) => { finishPermission = resolve; })
);
assert.equal(fileSelectionLifecycle.isPlatformNativeActivityActive(), true);
assert.equal(fileSelectionLifecycle.isPlatformFileSelectionActive(), false);
finishPermission('denied');
assert.equal(await permission, 'denied');
assert.equal(fileSelectionLifecycle.isPlatformNativeActivityActive(), false);
});
test('Android App 图片和视频使用系统媒体选择器', async () => {
globalThis.plus = androidPickerRuntime();
globalThis.uni = {
@@ -251,19 +264,23 @@ test('所有使用文件选择器的页面都按取消信号恢复空闲态而
assert.match(pages[6], /isFileSelectionCancelled\(error\)[\s\S]*?message\.value = ''[\s\S]*?return/);
});
test('会作废转写请求的页面在 Android 系统选择器打开时保留选择任务', async () => {
test('会作废转写请求的页面在 Android 系统权限框或选择器打开时保留选择任务', async () => {
const pages = await Promise.all([
'../src/pages/candidate/interview/index.vue',
'../src/pages/user/practice/index.vue',
'../src/pages/user/sop/index.vue',
'../src/pages/user/report/index.vue'
'../src/pages/user/report/index.vue',
'../src/pages/user/today/index.vue',
'../src/pages/user/direct/index.vue'
].map(read));
for (const page of pages) assert.match(page, /isPlatformFileSelectionActive\(\)/);
assert.match(pages[0], /if \(!isPlatformFileSelectionActive\(\)\) cancelQuestionRecording\(\)/);
assert.match(pages[1], /if \(!isPlatformFileSelectionActive\(\)\) cancelRecording\(\)/);
assert.match(pages[2], /if \(!isPlatformFileSelectionActive\(\)\) cancelVoiceTranscription\(\)/);
assert.match(pages[3], /teardown\(isPlatformFileSelectionActive\(\)\)/);
for (const page of pages) assert.match(page, /isPlatformNativeActivityActive\(\)/);
assert.match(pages[0], /if \(!isPlatformNativeActivityActive\(\)\) cancelQuestionRecording\(\)/);
assert.match(pages[1], /if \(!isPlatformNativeActivityActive\(\)\) cancelRecording\(\)/);
assert.match(pages[2], /if \(!isPlatformNativeActivityActive\(\)\)[\s\S]*?cancelVoiceTranscription\(\)/);
assert.match(pages[3], /teardown\(isPlatformNativeActivityActive\(\)\)/);
assert.match(pages[4], /if \(!isPlatformNativeActivityActive\(\)\)[\s\S]*?holdGeneration \+= 1/);
assert.match(pages[5], /if \(!isPlatformNativeActivityActive\(\)\) teardownVoice\(\)/);
});
test('Android SAF 复制完全在 Java FileChannel 内完成,不回退 Native.js byte 数组', async () => {
@@ -54,6 +54,7 @@ test('Android 运行时主动申请 RECORD_AUDIO 并区分拒绝与永久拒绝'
const requested = [];
globalThis.plus = {
android: {
runtimeMainActivity: () => ({ hasWindowFocus: () => true }),
requestPermissions: (permissions, success) => {
requested.push(...permissions);
success(pendingResult);
@@ -75,6 +76,42 @@ test('Android 运行时主动申请 RECORD_AUDIO 并区分拒绝与永久拒绝'
delete globalThis.plus;
});
test('权限 Activity 未归还窗口焦点前不结束授权流程,避免文件选择器被系统吞掉', async () => {
let focused = false;
let importedActivity = false;
const activity = { hasWindowFocus: () => focused };
globalThis.plus = {
android: {
importClass: (target) => {
assert.equal(target, activity);
importedActivity = true;
},
runtimeMainActivity: () => activity,
requestPermissions: (_permissions, success) => {
success({ deniedAlways: ['android.permission.RECORD_AUDIO'] });
}
}
};
const { requestMicrophonePermission } = await load();
const lifecycle = await vite.ssrLoadModule('/src/services/file-selection-lifecycle.ts');
let resolved = false;
const permission = requestMicrophonePermission().then((result) => {
resolved = true;
return result;
});
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(importedActivity, true);
assert.equal(lifecycle.isPlatformNativeActivityActive(), true);
assert.equal(lifecycle.isPlatformFileSelectionActive(), false);
assert.equal(resolved, false);
focused = true;
assert.equal(await permission, 'denied-always');
assert.equal(resolved, true);
assert.equal(lifecycle.isPlatformNativeActivityActive(), false);
});
test('非 App 运行时返回 unsupported,交由平台原生授权流程处理', async () => {
delete globalThis.plus;
const { requestMicrophonePermission } = await load();
+1 -1
View File
@@ -115,7 +115,7 @@ test('异步请求具备 generation、上传取消和离页 teardown', async ()
assert.match(page, /:disabled="draftLocked \|\| submitActionDisabled"/);
assert.match(page, /composerRef\.value\?\.teardown\(\)/);
assert.match(page, /speechPlayback\.stop\(\)/);
assert.match(page, /onHide\(\(\) => teardown\(isPlatformFileSelectionActive\(\)\)\)/);
assert.match(page, /onHide\(\(\) => teardown\(isPlatformNativeActivityActive\(\)\)\)/);
assert.match(page, /onUnload/);
});