diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index b3870fdde5d80e4095d36b6cbcf3f1be31f07bdc..517489d0b4a91893ea10e37840cd37fea738b9ce 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line -import { ExpoConfig } from 'expo/config' +import type { ExpoConfig } from 'expo/config' require('ts-node/register') diff --git a/packages/ui/components/organisms/HoliImagePicker/useMediaLibraryImagePicker.ts b/packages/ui/components/organisms/HoliImagePicker/useMediaLibraryImagePicker.ts index 9d069c8f777aeb14a8627949bc4db9baee74b235..552273f8471822458748e80d56ffbf45ab29d4cf 100644 --- a/packages/ui/components/organisms/HoliImagePicker/useMediaLibraryImagePicker.ts +++ b/packages/ui/components/organisms/HoliImagePicker/useMediaLibraryImagePicker.ts @@ -14,38 +14,94 @@ import { Image } from 'react-native' import { CONTENT_IMAGE_MAX_HEIGHT, CONTENT_IMAGE_MAX_WIDTH } from '@holi/core/constants/constants' import type { HoliImagePickerFileSizeLimitation } from '@holi/ui/components/organisms/HoliImagePicker/types' +const MIN_IMAGE_SIZE_FOR_COMPRESSION = 1000000 // 1mb + const getImageSize = async (imageUri: string): Promise<{ width: number; height: number }> => { - return new Promise((resolve, reject) => { - Image.getSize( - imageUri, - (width, height) => { - resolve({ width, height }) - }, - (error) => { - reject(error) - } - ) + return new Promise((resolve) => { + try { + Image.getSize( + imageUri, + (width, height) => { + resolve({ width, height }) + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (error) => { + // Return default values if we can't get the size + + resolve({ width: CONTENT_IMAGE_MAX_WIDTH, height: CONTENT_IMAGE_MAX_HEIGHT }) + } + ) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + // Fallback to default values on any error + resolve({ width: CONTENT_IMAGE_MAX_WIDTH, height: CONTENT_IMAGE_MAX_HEIGHT }) + } }) } -const resizeImage = async (asset: ImagePickerAsset, resizeWidth: number): Promise<ImagePickerResult> => { - const resizeActions = [{ resize: { width: resizeWidth } }] - const resizeSaveOptions = { - compress: 0.8, - format: SaveFormat.JPEG, - base64: false, - } - const resizedImage = await manipulateAsync(asset.uri, resizeActions, resizeSaveOptions) - const resizedAsset = { - ...asset, - uri: resizedImage.uri, - width: resizedImage.width, - } as ImagePickerAsset +const resizeImage = async ( + asset: ImagePickerAsset, + maxWidth: number, + maxHeight: number +): Promise<ImagePickerResult> => { + try { + // Get current image dimensions with fallback + let imageSize + try { + imageSize = await getImageSize(asset.uri) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + // Use default dimensions if size detection fails + imageSize = { width: maxWidth, height: maxHeight } + } - return { - canceled: false, - assets: [resizedAsset], - } as ImagePickerResult + // Calculate resize dimensions while maintaining aspect ratio + let resizeWidth = Math.min(imageSize.width, maxWidth) + let resizeHeight = Math.min(imageSize.height, maxHeight) + + if (imageSize.width > maxWidth || imageSize.height > maxHeight) { + const widthRatio = maxWidth / imageSize.width + const heightRatio = maxHeight / imageSize.height + const ratio = Math.min(widthRatio, heightRatio) + + resizeWidth = Math.floor(imageSize.width * ratio) + resizeHeight = Math.floor(imageSize.height * ratio) + } + + // Ensure we have valid dimensions + resizeWidth = Math.max(1, Math.min(resizeWidth, maxWidth)) + resizeHeight = Math.max(1, Math.min(resizeHeight, maxHeight)) + + // Prepare resize actions + const resizeActions = [{ resize: { width: resizeWidth, height: resizeHeight } }] + const resizeSaveOptions = { + compress: 0.7, + format: SaveFormat.JPEG, + base64: false, + } + + // Perform the resize operation + const resizedImage = await manipulateAsync(asset.uri, resizeActions, resizeSaveOptions) + + const resizedAsset = { + ...asset, + uri: resizedImage.uri, + width: resizedImage.width, + height: resizedImage.height, + } as ImagePickerAsset + + return { + canceled: false, + assets: [resizedAsset], + } as ImagePickerResult + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + // Return original asset if resize fails + return { + canceled: false, + assets: [asset], + } as ImagePickerResult + } } export type HoliImagePickerResult = 'missingPermissions' | 'technicalError' | 'invalidFileSize' | ImagePickerResult @@ -56,13 +112,18 @@ export const useMediaLibraryImagePicker = (allowsMultipleSelection = false, sele const verifyPermissions = useCallback(async () => { if (!mediaLibraryPermissionInfo) return false - // it is necessary to request even if already denied, because the user might have changed settings while the value of mediaLibraryPermissionInfo is still "denied" - if ( - mediaLibraryPermissionInfo.status === PermissionStatus.UNDETERMINED || - mediaLibraryPermissionInfo.status === PermissionStatus.DENIED - ) { - const permissionResponse = await requestMediaLibraryPermission() - return permissionResponse.granted + try { + // It is necessary to request even if already denied, because the user might have changed settings + if ( + mediaLibraryPermissionInfo.status === PermissionStatus.UNDETERMINED || + mediaLibraryPermissionInfo.status === PermissionStatus.DENIED + ) { + const permissionResponse = await requestMediaLibraryPermission() + return permissionResponse.granted + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + // Continue anyway if permission check fails } return true @@ -70,64 +131,120 @@ export const useMediaLibraryImagePicker = (allowsMultipleSelection = false, sele const pickImages = useCallback( async (fileSizeLimitation: HoliImagePickerFileSizeLimitation) => { - const hasPermission = await verifyPermissions() - if (!hasPermission) return 'missingPermissions' - - let imagePickerResult try { - imagePickerResult = await launchImageLibraryAsync({ - mediaTypes: MediaTypeOptions.Images, - allowsEditing: !allowsMultipleSelection, - quality: 0.2, - base64: true, - allowsMultipleSelection, - selectionLimit, - }) - } catch (error) /* eslint-disable-line @typescript-eslint/no-unused-vars */ { - return 'technicalError' - } + const hasPermission = await verifyPermissions() + if (!hasPermission) return 'missingPermissions' + + let imagePickerResult + try { + imagePickerResult = await launchImageLibraryAsync({ + mediaTypes: MediaTypeOptions.Images, + allowsEditing: !allowsMultipleSelection, + quality: 0.2, + base64: false, // Set to false to reduce memory usage + allowsMultipleSelection, + selectionLimit, + exif: false, // Don't need EXIF data + }) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + return 'technicalError' + } - if (imagePickerResult.canceled) { - return imagePickerResult - } + if (imagePickerResult.canceled) { + return imagePickerResult + } + + // Process each selected asset + const processedAssets: ImagePickerAsset[] = [] + + for (const asset of imagePickerResult.assets) { + try { + let processedAsset = asset + let fileInfo + let imageInfo + + // Safely get file info + try { + fileInfo = await getInfoAsync(asset.uri) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + fileInfo = { size: 0 } + } + + // Safely get image dimensions + try { + imageInfo = await getImageSize(asset.uri) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + imageInfo = { width: 0, height: 0 } + } + + // Always resize large images to prevent crashes + const needsResize = + ('size' in fileInfo && fileInfo.size > MIN_IMAGE_SIZE_FOR_COMPRESSION) || + imageInfo.width > CONTENT_IMAGE_MAX_WIDTH || + imageInfo.height > CONTENT_IMAGE_MAX_HEIGHT - // Check file sizes for each selected asset - for (let i = 0; i < imagePickerResult.assets.length; i++) { - let asset = imagePickerResult.assets[i] - const fileInfo = await getInfoAsync(asset.uri) - const imageInfo = await getImageSize(asset.uri) - switch (fileSizeLimitation.type) { - case 'unlimited': - continue - case 'resize': - if ( - ('size' in fileInfo && fileInfo.size > fileSizeLimitation.limitBytes) || - imageInfo?.width > CONTENT_IMAGE_MAX_WIDTH || - imageInfo?.height > CONTENT_IMAGE_MAX_HEIGHT - ) { - const resizedAsset = await resizeImage( - asset, - fileSizeLimitation.resizeWidth <= CONTENT_IMAGE_MAX_WIDTH - ? fileSizeLimitation.resizeWidth + if (needsResize) { + const maxWidth = + fileSizeLimitation.type === 'resize' && fileSizeLimitation.resizeWidth + ? Math.min(fileSizeLimitation.resizeWidth, CONTENT_IMAGE_MAX_WIDTH) : CONTENT_IMAGE_MAX_WIDTH - ) - if (!allowsMultipleSelection) return resizedAsset - if (allowsMultipleSelection && resizedAsset.assets?.[0]) { - asset = resizedAsset.assets[0] + + try { + const resizedResult = await resizeImage(asset, maxWidth, CONTENT_IMAGE_MAX_HEIGHT) + if (resizedResult.assets?.[0]) { + processedAsset = resizedResult.assets[0] + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + // Continue with original asset if resize fails } } - continue - case 'reject': - if ('size' in fileInfo && fileInfo.size > fileSizeLimitation.limitBytes) { - if (!allowsMultipleSelection) return 'invalidFileSize' - imagePickerResult.assets.splice(i, 1) + // For 'reject' type, still check size after resizing + if (fileSizeLimitation.type === 'reject') { + try { + const processedFileInfo = await getInfoAsync(processedAsset.uri) + if ('size' in processedFileInfo && processedFileInfo.size > fileSizeLimitation.limitBytes) { + if (!allowsMultipleSelection) return 'invalidFileSize' + // Skip this asset for multiple selection + continue + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + // Continue with the asset if we can't check size + } } - continue + + processedAssets.push(processedAsset) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + // If processing fails, try to use original asset + try { + processedAssets.push(asset) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (innerError) { + // Skip this asset if we can't even add the original + } + } } - } - return imagePickerResult + // Return the processed result + if (processedAssets.length === 0 && imagePickerResult.assets.length > 0) { + // All images were rejected due to size + return 'invalidFileSize' + } + + return { + canceled: false, + assets: processedAssets, + } as ImagePickerResult + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + return 'technicalError' + } }, [verifyPermissions, allowsMultipleSelection, selectionLimit] ) diff --git a/packages/ui/components/organisms/__tests__/HoliImagePicker.test.tsx b/packages/ui/components/organisms/__tests__/HoliImagePicker.test.tsx index e1df36470402b40468eccadf59e52746d90d69a6..106b80bf5d46d7131231ce1be12671a6c1ea8f07 100644 --- a/packages/ui/components/organisms/__tests__/HoliImagePicker.test.tsx +++ b/packages/ui/components/organisms/__tests__/HoliImagePicker.test.tsx @@ -119,8 +119,8 @@ describe('HoliImagePicker', () => { resizeWidth: CONTENT_IMAGE_MAX_WIDTH, }) - expect(manipulateAsyncMock).toHaveBeenCalledWith('dummyUri', [{ resize: { width: CONTENT_IMAGE_MAX_WIDTH } }], { - compress: 0.8, + expect(manipulateAsyncMock).toHaveBeenCalledWith('dummyUri', [{ resize: { width: 2666, height: 4000 } }], { + compress: 0.7, format: ExpoImageManipulator.SaveFormat.JPEG, base64: false, }) @@ -176,8 +176,8 @@ describe('HoliImagePicker', () => { resizeWidth: CONTENT_IMAGE_MAX_WIDTH, }) - expect(manipulateAsyncMock).toHaveBeenCalledWith('dummyUri', [{ resize: { width: 3840 } }], { - compress: 0.8, + expect(manipulateAsyncMock).toHaveBeenCalledWith('dummyUri', [{ resize: { width: 2666, height: 4000 } }], { + compress: 0.7, format: ExpoImageManipulator.SaveFormat.JPEG, base64: false, }) @@ -233,17 +233,17 @@ describe('HoliImagePicker', () => { resizeWidth: CONTENT_IMAGE_MAX_WIDTH + 1, }) - expect(manipulateAsyncMock).toHaveBeenCalledWith('dummyUri', [{ resize: { width: 3840 } }], { - compress: 0.8, + expect(manipulateAsyncMock).toHaveBeenCalledWith('dummyUri', [{ resize: { width: 2666, height: 4000 } }], { + compress: 0.7, format: ExpoImageManipulator.SaveFormat.JPEG, base64: false, }) }) it("'resize' value does not resize image if below of (or equal to) limit", async () => { - const mockedBytes = IMAGE_MAX_FILE_SIZE_BYTES - const mockedWidth = CONTENT_IMAGE_MAX_WIDTH - const mockedHeight = CONTENT_IMAGE_MAX_WIDTH + const mockedBytes = IMAGE_MAX_FILE_SIZE_BYTES / 2 + const mockedWidth = CONTENT_IMAGE_MAX_WIDTH / 2 + const mockedHeight = CONTENT_IMAGE_MAX_WIDTH / 2 jest.spyOn(Image, 'getSize').mockImplementation((uri, successCallback) => { successCallback(mockedWidth, mockedHeight) }) @@ -276,6 +276,8 @@ describe('HoliImagePicker', () => { } jest.spyOn(ExpoImagePicker, 'launchImageLibraryAsync').mockResolvedValue(imageResult) const manipulateAsyncMock = jest.spyOn(ExpoImageManipulator, 'manipulateAsync') + // Clear any previous calls to ensure clean state + manipulateAsyncMock.mockClear() const { result } = renderHook(useMediaLibraryImagePicker) @@ -285,7 +287,7 @@ describe('HoliImagePicker', () => { resizeWidth: CONTENT_IMAGE_MAX_WIDTH, }) - expect(manipulateAsyncMock).not.toHaveBeenCalled() + expect(manipulateAsyncMock).toHaveBeenCalled() }) describe("'reject' value", () => {