接入移动端壳相机图片导入能力
新增 HostBridge file.captureImage 契约与 H5 facade Expo 移动壳通过系统相机拍摄图片并复用图片导入校验 通用图片输入面板按宿主能力展示拍摄入口并转换为现有 File 回调 补充移动壳、HostBridge、图片面板测试和原生壳文档
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "允许 Genarrative 读取你选择的图片,用于导入创作素材和参考图。",
|
||||
"cameraPermission": false,
|
||||
"cameraPermission": "允许 Genarrative 使用相机拍摄创作素材和参考图。",
|
||||
"microphonePermission": false
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user