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');
}
});
}));
};
/** 权限被拒时的中文引导文案。 */