feat(release): close handler and microphone gaps
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}));
|
||||
};
|
||||
|
||||
/** 权限被拒时的中文引导文案。 */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user