fix(mobile): support Android system file picker

This commit is contained in:
key
2026-07-29 23:52:44 +08:00
parent 6a6b264f22
commit 5027bef32d
27 changed files with 680 additions and 130 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aihr-mobile-uni",
"version": "0.1.13",
"version": "0.1.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aihr-mobile-uni",
"version": "0.1.13",
"version": "0.1.16",
"dependencies": {
"@dcloudio/uni-app": "3.0.0-alpha-5020220260725001",
"@dcloudio/uni-app-plus": "3.0.0-alpha-5020220260725001",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package",
"name": "aihr-mobile-uni",
"version": "0.1.13",
"version": "0.1.16",
"private": true,
"type": "module",
"scripts": {
+2 -2
View File
@@ -3,8 +3,8 @@
"appid" : "__UNI__B36D8BE",
"description" : "帮道员工端",
"vueVersion" : "3",
"versionName" : "0.1.13",
"versionCode" : "113",
"versionName" : "0.1.16",
"versionCode" : "116",
"uniStatistics" : {
"enable" : false
},
@@ -137,6 +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 type {
AsrResponse,
InterviewFinishResponse,
@@ -500,7 +501,7 @@ onShow(() => {
onHide(() => {
speechPlayback.stop();
cancelQuestionRecording();
if (!isPlatformFileSelectionActive()) cancelQuestionRecording();
});
onUnload(() => {
+2 -1
View File
@@ -503,6 +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 ChatComposer from '@/components/chat/ChatComposer.vue';
import RealtimePracticeAppBridge from '@/components/practice/RealtimePracticeAppBridge.vue';
import type {
@@ -1851,7 +1852,7 @@ onHide(() => {
stopRealtime();
invalidateRequests();
speechPlayback.stop();
cancelRecording();
if (!isPlatformFileSelectionActive()) cancelRecording();
});
onUnload(() => {
+8 -5
View File
@@ -231,6 +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 { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { chooseKnowledgeMedia } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation';
@@ -769,13 +770,15 @@ const goLogin = () => {
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const teardown = () => {
const teardown = (preservePlatformFileSelection = false) => {
organizeGeneration += 1;
uploadGeneration += 1;
mediaSelectionGeneration += 1;
mineGeneration += 1;
submitGeneration += 1;
cancelVoiceTranscription();
if (!preservePlatformFileSelection) {
mediaSelectionGeneration += 1;
cancelVoiceTranscription();
}
activeUploadTask?.abort();
activeUploadTask = null;
speechPlayback.stop();
@@ -784,7 +787,7 @@ const teardown = () => {
uploading.value = false;
submitting.value = false;
loadingMine.value = false;
mediaSelecting.value = false;
if (!preservePlatformFileSelection) mediaSelecting.value = false;
};
const clearConversationState = () => {
@@ -833,7 +836,7 @@ onShow(() => {
}
});
onHide(teardown);
onHide(() => teardown(isPlatformFileSelectionActive()));
onUnload(() => {
teardown();
speechPlayback.destroy();
+2 -1
View File
@@ -424,6 +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 type {
AnswerFeedbackVerdict,
BroadcastKnowledgeContext,
@@ -1565,7 +1566,7 @@ onHide(() => {
cancelPendingBroadcastConsultation();
speechPlayback.stop();
composerRef.value?.teardown();
cancelVoiceTranscription();
if (!isPlatformFileSelectionActive()) cancelVoiceTranscription();
cancelPendingMediaSearch();
});
+7 -23
View File
@@ -7,7 +7,7 @@ import type {
} from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import { getAuth } from './auth';
import { fileSelectionFailure } from '../utils/file-selection';
import { chooseLocalFile } from './local-file-picker';
const materialExtensions = ['.pdf', '.doc', '.docx', '.jpg', '.jpeg', '.png', '.webp'];
@@ -38,28 +38,12 @@ export const getCandidateInterviewRecords = () =>
timeout: 15000
});
export const chooseCandidateFile = () =>
new Promise<CandidateSelectedFile>((resolve, reject) => {
uni.chooseFile({
count: 1,
type: 'all',
extension: materialExtensions,
success: (result) => {
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
const file = (tempFiles[0] || {}) as Partial<CandidateSelectedFile>;
const path = file.path || result.tempFilePaths?.[0] || '';
if (!path) {
reject(new Error('未获取到文件路径'));
return;
}
resolve({
path,
name: file.name || path.split('/').pop() || 'candidate-material',
size: Number(file.size || 0)
});
},
fail: (error) => reject(fileSelectionFailure(error, '文件'))
});
export const chooseCandidateFile = (): Promise<CandidateSelectedFile> =>
chooseLocalFile({
label: '文件',
mimeType: '*/*',
extensions: materialExtensions,
fallbackName: 'candidate-material'
});
export const uploadCandidateMaterial = (file: CandidateSelectedFile, materialType = '补充资料') =>
+7 -23
View File
@@ -8,32 +8,16 @@ import type {
CaseUploadResponse
} from '@/types/api';
import { apiRequest, apiUrl, authHeaders, authenticatedFileUrl, readTextPayload } from './api';
import { fileSelectionFailure } from '../utils/file-selection';
import { chooseLocalFile } from './local-file-picker';
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
export const chooseCaseAudio = () =>
new Promise<CaseSelectedFile>((resolve, reject) => {
uni.chooseFile({
count: 1,
type: 'all',
extension: audioExtensions,
success: (result) => {
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
const file = (tempFiles[0] || {}) as Partial<CaseSelectedFile>;
const path = file.path || result.tempFilePaths?.[0] || '';
if (!path) {
reject(new Error('未获取到文件路径'));
return;
}
resolve({
path,
name: file.name || path.split('/').pop() || 'case-audio',
size: Number(file.size || 0)
});
},
fail: (error) => reject(fileSelectionFailure(error, '音频'))
});
export const chooseCaseAudio = (): Promise<CaseSelectedFile> =>
chooseLocalFile({
label: '音频',
mimeType: 'audio/*',
extensions: audioExtensions,
fallbackName: 'case-audio'
});
export const uploadCaseAudio = (file: CaseSelectedFile) =>
@@ -0,0 +1,12 @@
let activePlatformFileSelections = 0;
export const isPlatformFileSelectionActive = () => activePlatformFileSelections > 0;
export const withPlatformFileSelection = async <T>(select: () => Promise<T>): Promise<T> => {
activePlatformFileSelections += 1;
try {
return await select();
} finally {
activePlatformFileSelections = Math.max(0, activePlatformFileSelections - 1);
}
};
+76 -12
View File
@@ -11,6 +11,8 @@ import type {
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import { getSelectedPosition } from './auth';
import { fileSelectionFailure } from '../utils/file-selection';
import { withPlatformFileSelection } from './file-selection-lifecycle';
import { isAndroidAppRuntime } from './local-file-picker';
type RawKnowledgeSnippet = KnowledgeSnippet & {
title?: string;
@@ -20,7 +22,44 @@ type RawKnowledgeSnippet = KnowledgeSnippet & {
const mediaExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v'];
const videoExtensions = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']);
export const chooseKnowledgeMedia = () =>
interface AppMediaFile {
tempFilePath?: string;
path?: string;
size?: number;
fileType?: 'image' | 'video';
}
interface AppChooseMediaResult {
tempFiles?: AppMediaFile[];
}
type UniWithChooseMedia = {
chooseMedia?: (options: {
count: number;
mediaType: Array<'image' | 'video'>;
sourceType: Array<'album'>;
success: (result: AppChooseMediaResult) => void;
fail: (error: unknown) => void;
}) => void;
};
const validateKnowledgeMedia = (
path: string,
name: string,
size: number,
declaredKind?: 'image' | 'video'
): KnowledgeMediaSelectedFile => {
if (!path) throw new Error('未获取到附件文件');
const extension = `.${name.split('.').pop()?.toLowerCase() || ''}`;
const kind = declaredKind || (videoExtensions.has(extension) ? 'video' : 'image');
const maxSize = kind === 'video' ? 100 * 1024 * 1024 : 10 * 1024 * 1024;
if (size > maxSize) {
throw new Error(kind === 'video' ? '视频不能超过 100MB' : '图片不能超过 10MB');
}
return { path, name, size, kind };
};
const chooseKnowledgeMediaWithFileInput = () =>
new Promise<KnowledgeMediaSelectedFile>((resolve, reject) => {
uni.chooseFile({
count: 1,
@@ -31,24 +70,49 @@ export const chooseKnowledgeMedia = () =>
const file = (tempFiles[0] || {}) as { path?: string; name?: string; size?: number };
const path = file.path || result.tempFilePaths?.[0] || '';
const name = file.name || path.split('/').pop() || '现场附件';
const extension = `.${name.split('.').pop()?.toLowerCase() || ''}`;
const kind = videoExtensions.has(extension) ? 'video' : 'image';
const size = Number(file.size || 0);
const maxSize = kind === 'video' ? 100 * 1024 * 1024 : 10 * 1024 * 1024;
if (!path) {
reject(new Error('未获取到附件文件'));
return;
try {
resolve(validateKnowledgeMedia(path, name, size));
} catch (error) {
reject(error);
}
if (size > maxSize) {
reject(new Error(kind === 'video' ? '视频不能超过 100MB' : '图片不能超过 10MB'));
return;
}
resolve({ path, name, size, kind });
},
fail: (error) => reject(fileSelectionFailure(error, '附件'))
});
});
const chooseKnowledgeMediaWithAppPicker = () =>
new Promise<KnowledgeMediaSelectedFile>((resolve, reject) => {
const chooseMedia = (uni as unknown as UniWithChooseMedia).chooseMedia;
if (!chooseMedia) {
reject(new Error('当前 App 版本无法选择图片或视频'));
return;
}
chooseMedia({
count: 1,
mediaType: ['image', 'video'],
sourceType: ['album'],
success: (result) => {
const file = result.tempFiles?.[0] || {};
const path = file.tempFilePath || file.path || '';
const name = path.split('/').pop() || (file.fileType === 'video' ? '现场视频.mp4' : '现场图片.jpg');
// App chooseMedia reports the selected file size in KiB.
const size = Math.max(0, Number(file.size || 0)) * 1024;
try {
resolve(validateKnowledgeMedia(path, name, size, file.fileType));
} catch (error) {
reject(error);
}
},
fail: (error) => reject(fileSelectionFailure(error, '附件'))
});
});
export const chooseKnowledgeMedia = () =>
isAndroidAppRuntime()
? withPlatformFileSelection(chooseKnowledgeMediaWithAppPicker)
: chooseKnowledgeMediaWithFileInput();
const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({
...snippet,
docTitle: snippet.docTitle || snippet.title || '',
@@ -0,0 +1,277 @@
import { FileSelectionCancelledError, fileSelectionFailure } from '../utils/file-selection';
import { withPlatformFileSelection } from './file-selection-lifecycle';
export interface LocalSelectedFile {
path: string;
name: string;
size: number;
}
export interface LocalFilePickerOptions {
label: string;
mimeType?: string;
extensions?: string[];
fallbackName?: string;
}
interface AndroidBridge {
importClass: (target: unknown) => any;
invoke: (target: unknown, method: string, ...args: unknown[]) => any;
runtimeMainActivity: () => any;
}
interface AppPlusRuntime {
os?: { name?: string };
android?: AndroidBridge;
}
const runtimePlus = () =>
(globalThis as typeof globalThis & { plus?: AppPlusRuntime }).plus;
export const isAndroidAppRuntime = () => {
const runtime = runtimePlus();
return runtime?.os?.name?.toLowerCase() === 'android' && Boolean(runtime.android);
};
const normalizeExtension = (value: string) => {
const extension = value.trim().toLowerCase();
if (!extension) return '';
return extension.startsWith('.') ? extension : `.${extension}`;
};
const safeFileName = (value: string, fallback: string) => {
const normalized = value
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
.replace(/\s+/g, ' ')
.trim()
.slice(-120);
return normalized || fallback;
};
const selectedExtension = (name: string) => {
const index = name.lastIndexOf('.');
return index >= 0 ? name.slice(index).toLowerCase() : '';
};
const validateExtension = (name: string, options: LocalFilePickerOptions) => {
const accepted = (options.extensions || []).map(normalizeExtension).filter(Boolean);
if (!accepted.length || !name) return;
if (!accepted.includes(selectedExtension(name))) {
throw new Error(`不支持该${options.label}格式`);
}
};
const chooseWithUni = (options: LocalFilePickerOptions) =>
new Promise<LocalSelectedFile>((resolve, reject) => {
uni.chooseFile({
count: 1,
type: 'all',
extension: options.extensions,
success: (result) => {
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
const file = (tempFiles[0] || {}) as Partial<LocalSelectedFile>;
const path = file.path || result.tempFilePaths?.[0] || '';
if (!path) {
reject(new Error(`未获取到${options.label}文件`));
return;
}
const name = file.name || path.split('/').pop() || options.fallbackName || 'selected-file';
try {
validateExtension(name, options);
resolve({ path, name, size: Number(file.size || 0) });
} catch (error) {
reject(error);
}
},
fail: (error) => reject(fileSelectionFailure(error, options.label))
});
});
const readAndroidMetadata = (bridge: AndroidBridge, resolver: any, uri: any) => {
let cursor: any = null;
try {
cursor = resolver.query(uri, null, null, null, null);
if (!cursor) return { name: '', size: 0 };
bridge.importClass(cursor);
if (!cursor.moveToFirst()) return { name: '', size: 0 };
const nameIndex = Number(cursor.getColumnIndex('_display_name'));
const sizeIndex = Number(cursor.getColumnIndex('_size'));
return {
name: nameIndex >= 0 ? String(cursor.getString(nameIndex) || '') : '',
size: sizeIndex >= 0 ? Number(cursor.getLong(sizeIndex) || 0) : 0
};
} catch {
return { name: '', size: 0 };
} finally {
try {
cursor?.close();
} catch {
// Metadata is optional; stream copying below remains authoritative.
}
}
};
const copyAndroidUriToCache = (
bridge: AndroidBridge,
main: any,
uri: any,
options: LocalFilePickerOptions
): LocalSelectedFile => {
const resolver = main.getContentResolver();
bridge.importClass(resolver);
const metadata = readAndroidMetadata(bridge, resolver, uri);
if (metadata.name) validateExtension(metadata.name, options);
const File = bridge.importClass('java.io.File');
const FileInputStream = bridge.importClass('java.io.FileInputStream');
const FileOutputStream = bridge.importClass('java.io.FileOutputStream');
// uni.uploadFile on App reliably consumes app-scoped external cache paths.
// Files under /data/user/0/... can be opened by this activity but may be
// rejected or truncated by DCloud's native multipart uploader.
const cacheRoot = main.getExternalCacheDir() || main.getCacheDir();
bridge.importClass(cacheRoot);
const pickerDirectory = new File(cacheRoot, 'aihr-picker');
pickerDirectory.mkdirs();
const fallbackName = options.fallbackName || `selected-${options.label}`;
const name = safeFileName(metadata.name, fallbackName);
const target = new File(pickerDirectory, `${Date.now()}-${name}`);
const descriptor = resolver.openFileDescriptor(uri, 'r');
if (!descriptor) throw new Error(`无法读取所选${options.label}`);
bridge.importClass(descriptor);
const fileDescriptor = descriptor.getFileDescriptor();
bridge.importClass(fileDescriptor);
const input = new FileInputStream(fileDescriptor);
const output = new FileOutputStream(target);
bridge.importClass(input);
bridge.importClass(output);
const sourceChannel = input.getChannel();
const targetChannel = output.getChannel();
bridge.importClass(sourceChannel);
bridge.importClass(targetChannel);
let copied = 0;
try {
while (true) {
// Native.js cannot reliably marshal Java byte[] arguments. FileChannel
// keeps the SAF content copy entirely on the Java side.
const count = Number(bridge.invoke(
targetChannel,
'transferFrom',
sourceChannel,
copied,
16 * 1024 * 1024
));
if (!Number.isFinite(count) || count <= 0) break;
copied += count;
}
output.flush();
} catch {
try {
target.delete();
} catch {
// The cache directory is private and may be cleaned by the system.
}
throw new Error(`无法读取所选${options.label}`);
} finally {
try {
input.close();
} catch {
// Best-effort native stream cleanup.
}
try {
output.close();
} catch {
// Best-effort native stream cleanup.
}
try {
descriptor.close();
} catch {
// Best-effort descriptor cleanup.
}
}
const path = String(target.getAbsolutePath() || '');
if (!path || copied <= 0) {
try {
target.delete();
} catch {
// The empty cache file is harmless if the platform already removed it.
}
throw new Error(`未获取到${options.label}文件`);
}
return {
path,
name,
size: copied || metadata.size
};
};
const chooseWithAndroidDocumentPicker = (options: LocalFilePickerOptions) =>
new Promise<LocalSelectedFile>((resolve, reject) => {
const runtime = runtimePlus();
const bridge = runtime?.android;
if (!bridge) {
reject(new Error(`无法选择${options.label},请重试`));
return;
}
const main = bridge.runtimeMainActivity();
const Intent = bridge.importClass('android.content.Intent');
const Activity = bridge.importClass('android.app.Activity');
const requestCode = 0x4a17;
const previousHandler = main.onActivityResult;
let settled = false;
const restoreHandler = () => {
if (main.onActivityResult === onActivityResult) {
main.onActivityResult = previousHandler || null;
}
};
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
restoreHandler();
callback();
};
const onActivityResult = (incomingCode: number, resultCode: number, data: any) => {
if (incomingCode !== requestCode) {
if (typeof previousHandler === 'function') previousHandler(incomingCode, resultCode, data);
return;
}
if (resultCode !== Activity.RESULT_OK || !data) {
finish(() => reject(new FileSelectionCancelledError(options.label)));
return;
}
try {
bridge.importClass(data);
const uri = data.getData();
if (!uri) {
finish(() => reject(new FileSelectionCancelledError(options.label)));
return;
}
bridge.importClass(uri);
const selected = copyAndroidUriToCache(bridge, main, uri, options);
finish(() => resolve(selected));
} catch (error) {
finish(() => reject(error instanceof Error
? error
: new Error(`无法选择${options.label},请重试`)));
}
};
main.onActivityResult = onActivityResult;
try {
const intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(options.mimeType || '*/*');
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
main.startActivityForResult(intent, requestCode);
} catch (error) {
finish(() => reject(fileSelectionFailure(error, options.label)));
}
});
export const chooseLocalFile = (options: LocalFilePickerOptions) =>
isAndroidAppRuntime()
? withPlatformFileSelection(() => chooseWithAndroidDocumentPicker(options))
: chooseWithUni(options);
@@ -17,7 +17,7 @@ import type {
PersonalSpace,
ServiceMemory
} from '@/types/api';
import { fileSelectionFailure } from '../utils/file-selection';
import { chooseLocalFile } from './local-file-picker';
import type { CaptureStatusHistory } from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import { personalItemApiPath, personalSessionApiPath } from './personal-assistant-id';
@@ -140,14 +140,8 @@ export const createUrlItem = (data: { url: string; title?: string; capturedAt?:
apiRequest<PersonalItemCreated>({ url: '/api/aihr/personal-assistant/items/url', method: 'POST', data, timeout: 45000 });
export interface PersonalSelectedFile { path: string; name: string; size: number; }
export const choosePersonalFile = () => new Promise<PersonalSelectedFile>((resolve, reject) => {
uni.chooseFile({ count: 1, type: 'all', success: (result) => {
const file = (Array.isArray(result.tempFiles) ? result.tempFiles[0] : undefined) as Partial<PersonalSelectedFile> | undefined;
const path = file?.path || result.tempFilePaths?.[0] || '';
if (!path) return reject(new Error('未获取到文件路径'));
resolve({ path, name: file?.name || path.split('/').pop() || 'personal-file', size: Number(file?.size || 0) });
}, fail: (error) => reject(fileSelectionFailure(error, '文件')) });
});
export const choosePersonalFile = (): Promise<PersonalSelectedFile> =>
chooseLocalFile({ label: '文件', mimeType: '*/*', fallbackName: 'personal-file' });
export const uploadPersonalFile = (file: PersonalSelectedFile, title?: string, capturedAt?: string) =>
new Promise<PersonalItemCreated>((resolve, reject) => uni.uploadFile({
+9 -1
View File
@@ -1,4 +1,5 @@
import type { SpeechSelectedFile } from '@/types/api';
import { microphoneDeniedMessage, requestMicrophonePermission } from '../utils/microphone';
export type SpeechCapture =
| { kind: 'blob'; blob: Blob; filename: string }
@@ -163,7 +164,14 @@ const startBrowserCapture = async (handlers: SpeechCaptureHandlers): Promise<Spe
* native temporary file; H5 preserves the existing MediaRecorder blob flow.
*/
export const startSpeechCapture = async (handlers: SpeechCaptureHandlers): Promise<SpeechCaptureSession | null> => {
if (isAppRuntime()) return startNativeCapture(handlers);
if (isAppRuntime()) {
const permission = await requestMicrophonePermission();
if (permission === 'denied' || permission === 'denied-always') {
handlers.onError?.(new Error(microphoneDeniedMessage(permission)));
return null;
}
return startNativeCapture(handlers);
}
return startBrowserCapture(handlers);
};
+7 -23
View File
@@ -5,7 +5,7 @@ import type { SpeechCapture } from './speech-capture';
import { estimateMpegAudioDuration } from './mpeg-audio-duration';
import { dataUriToBlobUrl, decodeDataAudioUri, hasNativeAudioFileSupport, isDataAudioUri, removeNativeTempAudio, writeNativeTempAudio } from './speech-audio-source';
import { applyTtsVoicePreference, getTtsDialectPreference, getTtsVoicePreference } from './tts-voice-preference';
import { fileSelectionFailure } from '../utils/file-selection';
import { chooseLocalFile } from './local-file-picker';
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
@@ -26,28 +26,12 @@ export const normalizeOssId = (value: unknown): string | undefined => {
return /^\d+$/.test(text) ? text : undefined;
};
export const chooseSpeechAudio = () =>
new Promise<SpeechSelectedFile>((resolve, reject) => {
uni.chooseFile({
count: 1,
type: 'all',
extension: audioExtensions,
success: (result) => {
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
const file = (tempFiles[0] || {}) as Partial<SpeechSelectedFile>;
const path = file.path || result.tempFilePaths?.[0] || '';
if (!path) {
reject(new Error('未获取到音频文件'));
return;
}
resolve({
path,
name: file.name || path.split('/').pop() || 'practice-audio',
size: Number(file.size || 0)
});
},
fail: (error) => reject(fileSelectionFailure(error, '音频'))
});
export const chooseSpeechAudio = (): Promise<SpeechSelectedFile> =>
chooseLocalFile({
label: '音频',
mimeType: 'audio/*',
extensions: audioExtensions,
fallbackName: 'practice-audio'
});
export type SpeechAbortRegistrar = (abort: (() => void) | null) => void;
+171
View File
@@ -13,12 +13,15 @@ const vite = await createServer({
});
const originalUni = globalThis.uni;
const originalPlus = globalThis.plus;
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8');
after(async () => {
await vite.close();
if (originalUni === undefined) delete globalThis.uni;
else globalThis.uni = originalUni;
if (originalPlus === undefined) delete globalThis.plus;
else globalThis.plus = originalPlus;
});
const [
@@ -36,6 +39,7 @@ const [
vite.ssrLoadModule('/src/services/knowledge.ts'),
vite.ssrLoadModule('/src/services/personal-assistant.ts')
]);
const fileSelectionLifecycle = await vite.ssrLoadModule('/src/services/file-selection-lifecycle.ts');
const pickers = [
['音频', speech.chooseSpeechAudio],
@@ -45,6 +49,93 @@ const pickers = [
['文件', personalAssistant.choosePersonalFile]
];
const androidPickerRuntime = ({ cancelled = false } = {}) => {
class FakeIntent {
static ACTION_OPEN_DOCUMENT = 'android.intent.action.OPEN_DOCUMENT';
static CATEGORY_OPENABLE = 'android.intent.category.OPENABLE';
static FLAG_GRANT_READ_URI_PERMISSION = 1;
constructor(action) { this.action = action; }
addCategory(category) { this.category = category; }
setType(type) { this.type = type; }
addFlags(flags) { this.flags = flags; }
}
class FakeActivity {
static RESULT_OK = 1;
}
class FakeFile {
constructor(parent, name) {
this.path = `${typeof parent === 'string' ? parent : parent.path}/${name}`;
}
mkdirs() {}
delete() {}
getAbsolutePath() { return this.path; }
}
class FakeFileOutputStream {
constructor(target) { this.target = target; }
getChannel() {
return {
transferFrom(source) {
const count = source.remaining;
source.remaining = 0;
return count;
}
};
}
flush() {}
close() {}
}
class FakeFileInputStream {
constructor(fileDescriptor) { this.fileDescriptor = fileDescriptor; }
getChannel() { return { remaining: 3 }; }
close() {}
}
const cursor = {
moveToFirst: () => true,
getColumnIndex: (name) => name === '_display_name' ? 0 : 1,
getString: () => 'acceptance.mp3',
getLong: () => 3,
close() {}
};
const resolver = {
query: () => cursor,
openFileDescriptor: () => ({
getFileDescriptor: () => ({}),
close() {}
})
};
const main = {
onActivityResult: null,
getCacheDir: () => ({ path: '/cache' }),
getExternalCacheDir: () => ({ path: '/external-cache' }),
getContentResolver: () => resolver,
startActivityForResult(intent, requestCode) {
assert.equal(intent.action, FakeIntent.ACTION_OPEN_DOCUMENT);
assert.equal(intent.category, FakeIntent.CATEGORY_OPENABLE);
assert.equal(intent.type, 'audio/*');
queueMicrotask(() => this.onActivityResult(
requestCode,
cancelled ? 0 : FakeActivity.RESULT_OK,
cancelled ? null : { getData: () => ({}) }
));
}
};
const classes = {
'android.content.Intent': FakeIntent,
'android.app.Activity': FakeActivity,
'java.io.File': FakeFile,
'java.io.FileInputStream': FakeFileInputStream,
'java.io.FileOutputStream': FakeFileOutputStream
};
return {
os: { name: 'Android' },
android: {
importClass: (target) => typeof target === 'string' ? classes[target] : target,
invoke: (target, method, ...args) => target[method](...args),
runtimeMainActivity: () => main
}
};
};
test('所有文件选择器把用户取消识别为同一可静默处理的信号', async () => {
globalThis.uni = {
chooseFile: ({ fail }) => fail({ errMsg: 'chooseFile:fail cancel' })
@@ -81,6 +172,65 @@ test('无结构异常也不会被误判为取消', () => {
assert.equal(error.message, '无法选择附件,请重试');
});
test('Android App 使用系统文档选择器并复制音频到应用缓存', async () => {
globalThis.plus = androidPickerRuntime();
globalThis.uni = { chooseFile: () => assert.fail('Android App 不应调用不受支持的 uni.chooseFile') };
const selected = await speech.chooseSpeechAudio();
assert.equal(selected.name, 'acceptance.mp3');
assert.equal(selected.size, 3);
assert.match(selected.path, /^\/external-cache\/aihr-picker\/\d+-acceptance\.mp3$/);
delete globalThis.plus;
});
test('Android 文件选择期间保持页面请求有效,并在完成后恢复生命周期状态', async () => {
let finishSelection;
const selection = fileSelectionLifecycle.withPlatformFileSelection(() =>
new Promise((resolve) => { finishSelection = resolve; })
);
assert.equal(fileSelectionLifecycle.isPlatformFileSelectionActive(), true);
finishSelection('selected');
assert.equal(await selection, 'selected');
assert.equal(fileSelectionLifecycle.isPlatformFileSelectionActive(), false);
});
test('Android App 图片和视频使用系统媒体选择器', async () => {
globalThis.plus = androidPickerRuntime();
globalThis.uni = {
chooseFile: () => assert.fail('Android App 媒体不应调用不受支持的 uni.chooseFile'),
chooseMedia: ({ mediaType, sourceType, success }) => {
assert.deepEqual(mediaType, ['image', 'video']);
assert.deepEqual(sourceType, ['album']);
success({
tempFiles: [{
tempFilePath: '/cache/acceptance.mp4',
fileType: 'video',
size: 12
}]
});
}
};
const selected = await knowledge.chooseKnowledgeMedia();
assert.equal(selected.path, '/cache/acceptance.mp4');
assert.equal(selected.kind, 'video');
assert.equal(selected.size, 12 * 1024);
delete globalThis.plus;
});
test('Android 系统文档选择器取消仍返回统一取消信号', async () => {
globalThis.plus = androidPickerRuntime({ cancelled: true });
globalThis.uni = { chooseFile: () => assert.fail('Android App 不应调用不受支持的 uni.chooseFile') };
await assert.rejects(speech.chooseSpeechAudio(), (error) => {
assert.equal(fileSelection.isFileSelectionCancelled(error), true);
assert.equal(error.message, '未选择音频');
return true;
});
delete globalThis.plus;
});
test('所有使用文件选择器的页面都按取消信号恢复空闲态而不展示错误', async () => {
const pages = await Promise.all([
'../src/pages/candidate/interview/index.vue',
@@ -100,3 +250,24 @@ test('所有使用文件选择器的页面都按取消信号恢复空闲态而
assert.match(pages[0], /isFileSelectionCancelled\(error\)[\s\S]*?message\.value = ''[\s\S]*?return/);
assert.match(pages[6], /isFileSelectionCancelled\(error\)[\s\S]*?message\.value = ''[\s\S]*?return/);
});
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'
].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\(\)\)/);
});
test('Android SAF 复制完全在 Java FileChannel 内完成,不回退 Native.js byte 数组', async () => {
const picker = await read('../src/services/local-file-picker.ts');
assert.match(picker, /transferFrom/);
assert.doesNotMatch(picker, /newObject\(\s*['"]byte\[\]/);
});
@@ -0,0 +1,48 @@
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;
delete globalThis.uni;
});
test('App recording falls back before RecorderManager starts when microphone permission is denied', async () => {
let recorderRequested = false;
globalThis.plus = {
android: {
requestPermissions: (_permissions, success) => {
success({ deniedAlways: ['android.permission.RECORD_AUDIO'] });
}
}
};
globalThis.uni = {
getRecorderManager: () => {
recorderRequested = true;
throw new Error('RecorderManager should not start when permission is denied');
}
};
const { startSpeechCapture } = await vite.ssrLoadModule('/src/services/speech-capture.ts');
let fallbackError;
const session = await startSpeechCapture({
onCapture: () => assert.fail('denied permission must not return a recording'),
onError: (error) => {
fallbackError = error;
}
});
assert.equal(session, null);
assert.equal(recorderRequested, false);
assert.ok(fallbackError instanceof Error);
});
+1 -1
View File
@@ -114,7 +114,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\)/);
assert.match(page, /onHide\(\(\) => teardown\(isPlatformFileSelectionActive\(\)\)\)/);
assert.match(page, /onUnload/);
});