接入移动端壳相机图片导入能力

新增 HostBridge file.captureImage 契约与 H5 facade

Expo 移动壳通过系统相机拍摄图片并复用图片导入校验

通用图片输入面板按宿主能力展示拍摄入口并转换为现有 File 回调

补充移动壳、HostBridge、图片面板测试和原生壳文档
This commit is contained in:
2026-06-18 06:03:45 +08:00
parent b3278739a5
commit f7126f9556
13 changed files with 509 additions and 76 deletions

View File

@@ -15,7 +15,7 @@
"expo-image-picker",
{
"photosPermission": "允许 Genarrative 读取你选择的图片,用于导入创作素材和参考图。",
"cameraPermission": false,
"cameraPermission": "允许 Genarrative 使用相机拍摄创作素材和参考图。",
"microphonePermission": false
}
],

View File

@@ -155,11 +155,11 @@ if (Array.isArray(imagePickerPlugin)) {
if (typeof pluginOptions.photosPermission !== 'string') {
throw new Error('mobile shell image picker photo permission text is missing');
}
if (
pluginOptions.cameraPermission !== false ||
pluginOptions.microphonePermission !== false
) {
throw new Error('mobile shell image picker must not request camera or microphone');
if (typeof pluginOptions.cameraPermission !== 'string') {
throw new Error('mobile shell image picker camera permission text is missing');
}
if (pluginOptions.microphonePermission !== false) {
throw new Error('mobile shell image picker must not request microphone');
}
}
@@ -182,6 +182,7 @@ for (const snippet of [
'file.importText',
'file.exportImage',
'file.importImage',
'file.captureImage',
'file.importAudio',
'file.exportAudio',
'clipboard.readText',
@@ -197,7 +198,9 @@ for (const snippet of [
'DocumentPicker.getDocumentAsync',
'Clipboard.getStringAsync',
'ImagePicker.launchImageLibraryAsync',
'ImagePicker.launchCameraAsync',
'ImagePicker.requestMediaLibraryPermissionsAsync',
'ImagePicker.requestCameraPermissionsAsync',
'File(asset.uri)',
'file.base64()',
'normalizeHostBridgeExportFileName',
@@ -236,6 +239,7 @@ for (const capability of [
'file.importText',
'file.exportImage',
'file.importImage',
'file.captureImage',
'file.importAudio',
'file.exportAudio',
'haptics.impact',

View File

@@ -109,7 +109,9 @@ vi.mock('expo-image-picker', () => ({
DENIED: 'denied',
GRANTED: 'granted',
},
launchCameraAsync: vi.fn(),
launchImageLibraryAsync: vi.fn(),
requestCameraPermissionsAsync: vi.fn(),
requestMediaLibraryPermissionsAsync: vi.fn(),
}));
@@ -230,6 +232,18 @@ afterEach(() => {
canceled: true,
assets: null,
});
vi.mocked(ImagePicker.launchCameraAsync).mockReset();
vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValue({
canceled: true,
assets: null,
});
vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockReset();
vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValue({
status: ImagePicker.PermissionStatus.GRANTED,
granted: true,
canAskAgain: true,
expires: 'never',
});
vi.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockReset();
vi.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockResolvedValue({
status: ImagePicker.PermissionStatus.GRANTED,
@@ -292,6 +306,9 @@ describe('handleMobileHostBridgeMessage', () => {
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('file.importImage');
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('file.captureImage');
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('file.importAudio');
@@ -1046,6 +1063,71 @@ describe('handleMobileHostBridgeMessage', () => {
expect(expectFailed(oversized).error.code).toBe('invalid_request');
});
test('file.captureImage 调起系统相机并返回受控图片数据', async () => {
vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/camera.jpg',
width: 120,
height: 80,
type: 'image',
fileName: null,
fileSize: 6,
base64: 'Y2FtZXJh',
mimeType: 'image/jpeg',
},
],
});
const response = await send(request('file.captureImage'));
expect(expectOk(response).result).toEqual({
action: 'captured',
fileName: 'genarrative-import.jpg',
base64Data: 'Y2FtZXJh',
mimeType: 'image/jpeg',
bytes: 6,
});
expect(ImagePicker.requestCameraPermissionsAsync).toHaveBeenCalled();
expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith({
allowsEditing: false,
base64: true,
exif: false,
mediaTypes: ['images'],
quality: 1,
});
});
test('file.captureImage 拒绝权限和取消拍摄', async () => {
vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValue({
status: ImagePicker.PermissionStatus.DENIED,
granted: false,
canAskAgain: false,
expires: 'never',
});
const denied = await send(request('file.captureImage'));
expect(expectFailed(denied).error.code).toBe('host_error');
expect(ImagePicker.launchCameraAsync).not.toHaveBeenCalled();
vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValue({
status: ImagePicker.PermissionStatus.GRANTED,
granted: true,
canAskAgain: true,
expires: 'never',
});
vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValue({
canceled: true,
assets: null,
});
const cancelled = await send(request('file.captureImage'));
expect(expectFailed(cancelled).error.code).toBe('cancelled');
});
test('file.importAudio 调起系统文档选择器并返回受控音频数据', async () => {
fileBase64Data.set('file:///private/mobile/hit.webm', 'YXVkaW8=');
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({

View File

@@ -105,6 +105,7 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
'file.importText',
'file.exportImage',
'file.importImage',
'file.captureImage',
'file.importAudio',
'file.exportAudio',
'haptics.impact',
@@ -559,23 +560,10 @@ async function exportAudioFile(
};
}
async function importImageFile(): Promise<FileImportImageResult> {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permission.status !== ImagePicker.PermissionStatus.GRANTED) {
throw {
code: 'host_error',
message: 'photo library permission denied',
} satisfies HostBridgeError;
}
const result = await ImagePicker.launchImageLibraryAsync({
allowsEditing: false,
allowsMultipleSelection: false,
base64: true,
exif: false,
mediaTypes: ['images'],
quality: 1,
});
function imagePickerResultToImportPayload(
result: ImagePicker.ImagePickerResult,
action: FileImportImageResult['action'],
): FileImportImageResult {
if (result.canceled) {
throw {
code: 'cancelled',
@@ -610,7 +598,7 @@ async function importImageFile(): Promise<FileImportImageResult> {
}
return {
action: 'selected',
action,
fileName: normalizeHostBridgeExportFileName(
asset.fileName || fallbackImportedImageFileName(mimeType),
),
@@ -620,6 +608,47 @@ async function importImageFile(): Promise<FileImportImageResult> {
};
}
async function importImageFile(): Promise<FileImportImageResult> {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permission.status !== ImagePicker.PermissionStatus.GRANTED) {
throw {
code: 'host_error',
message: 'photo library permission denied',
} satisfies HostBridgeError;
}
const result = await ImagePicker.launchImageLibraryAsync({
allowsEditing: false,
allowsMultipleSelection: false,
base64: true,
exif: false,
mediaTypes: ['images'],
quality: 1,
});
return imagePickerResultToImportPayload(result, 'selected');
}
async function captureImageFile(): Promise<FileImportImageResult> {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (permission.status !== ImagePicker.PermissionStatus.GRANTED) {
throw {
code: 'host_error',
message: 'camera permission denied',
} satisfies HostBridgeError;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: false,
base64: true,
exif: false,
mediaTypes: ['images'],
quality: 1,
});
return imagePickerResultToImportPayload(result, 'captured');
}
async function importAudioFile(): Promise<FileImportAudioResult> {
const result = await DocumentPicker.getDocumentAsync({
copyToCacheDirectory: true,
@@ -908,6 +937,8 @@ async function handleRequest(request: HostBridgeRequest) {
return ok(request, await exportImageFile(request.payload));
case 'file.importImage':
return ok(request, await importImageFile());
case 'file.captureImage':
return ok(request, await captureImageFile());
case 'file.importAudio':
return ok(request, await importAudioFile());
case 'file.exportAudio':