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
+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/);
});