fix(mobile): support Android system file picker
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user