From 83d27285e1e240db53c0d444c4fba2d4529d6b53 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 4 Sep 2026 14:31:10 +0300 Subject: [PATCH] migrate media library api --- jest/jestSetup.js | 16 +++--- src/components/StarterPack/QrCodeDialog.tsx | 10 ++-- .../InviteFriendsDialogInner.tsx | 11 ++-- src/lib/hooks/usePermissions.ts | 6 +-- src/lib/media/manip.ts | 35 +++--------- src/lib/media/media-library.ts | 6 +++ src/lib/media/media-library.web.ts | 54 +++++++++++++++++++ src/lib/media/save-image.ts | 9 ++-- .../com/composer/photos/OpenCameraBtn.tsx | 23 +++++--- 9 files changed, 103 insertions(+), 67 deletions(-) create mode 100644 src/lib/media/media-library.ts create mode 100644 src/lib/media/media-library.web.ts diff --git a/jest/jestSetup.js b/jest/jestSetup.js index 562c1063c7..43e4584f4d 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -75,18 +75,16 @@ jest.mock('expo-camera', () => ({ jest.mock('expo-media-library', () => ({ __esModule: true, // this property makes it work + Album: { + create: jest.fn(), + get: jest.fn(), + }, + Asset: { + create: jest.fn(), + }, default: jest.fn(), usePermissions: jest.fn(() => [true]), requestPermissionsAsync: jest.fn().mockResolvedValue({granted: true}), - saveToLibraryAsync: jest.fn().mockResolvedValue(undefined), -})) - -jest.mock('expo-media-library/legacy', () => ({ - __esModule: true, - default: jest.fn(), - usePermissions: jest.fn(() => [true]), - requestPermissionsAsync: jest.fn().mockResolvedValue({granted: true}), - saveToLibraryAsync: jest.fn().mockResolvedValue(undefined), })) jest.mock('@bsky.app/expo-guess-language', () => ({ diff --git a/src/components/StarterPack/QrCodeDialog.tsx b/src/components/StarterPack/QrCodeDialog.tsx index 95c2a51223..923112b94c 100644 --- a/src/components/StarterPack/QrCodeDialog.tsx +++ b/src/components/StarterPack/QrCodeDialog.tsx @@ -1,15 +1,12 @@ import {Suspense, useRef, useState} from 'react' import {View} from 'react-native' import {type ViewShotRef} from 'react-native-view-shot' -import { - requestPermissionsAsync, - saveToLibraryAsync, -} from 'expo-media-library/legacy' import * as Sharing from 'expo-sharing' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' +import {Asset, requestPermissionsAsync} from '#/lib/media/media-library' import {logger} from '#/logger' import {atoms as a, useBreakpoints} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -77,9 +74,8 @@ export function QrCodeDialog({ // Incase of a FS failure, don't crash the app try { - // saveToLibraryAsync writes without reading the asset back, so it - // works with the add-only permission on iOS (APP-2374) - await saveToLibraryAsync(`file://${uri}`) + // Asset.create supports the add-only permission on iOS (APP-2374). + await Asset.create(`file://${uri}`) } catch (e: unknown) { Toast.show(_(msg`An error occurred while saving the QR code!`), { type: 'error', diff --git a/src/features/inviteFriends/InviteFriendsDialogInner.tsx b/src/features/inviteFriends/InviteFriendsDialogInner.tsx index c29aef39fd..26079344f3 100644 --- a/src/features/inviteFriends/InviteFriendsDialogInner.tsx +++ b/src/features/inviteFriends/InviteFriendsDialogInner.tsx @@ -2,13 +2,10 @@ import {Suspense, useRef} from 'react' import {Pressable, View} from 'react-native' import {type ViewShotRef} from 'react-native-view-shot' import {setStringAsync} from 'expo-clipboard' -import { - requestPermissionsAsync, - saveToLibraryAsync, -} from 'expo-media-library/legacy' import {useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' +import {Asset, requestPermissionsAsync} from '#/lib/media/media-library' import {type NavigationProp} from '#/lib/routes/types' import {shareUrl as nativeShareUrl} from '#/lib/sharing' import {logger} from '#/logger' @@ -96,10 +93,8 @@ export function InviteFriendsDialogInner({ } try { - // saveToLibraryAsync writes without reading the asset back, so it works - // with the add-only permission. createAssetAsync fetches the created - // asset, which triggers the full library read prompt on iOS (APP-2374). - await saveToLibraryAsync(`file://${uri}`) + // Asset.create supports the add-only permission on iOS (APP-2374). + await Asset.create(`file://${uri}`) ax.metric('invite:action:download', {}) Toast.show(l`QR code saved to your camera roll!`) } catch (err) { diff --git a/src/lib/hooks/usePermissions.ts b/src/lib/hooks/usePermissions.ts index 30494acf48..36728bc163 100644 --- a/src/lib/hooks/usePermissions.ts +++ b/src/lib/hooks/usePermissions.ts @@ -1,7 +1,7 @@ import {Linking} from 'react-native' import {useCameraPermissions as useExpoCameraPermissions} from 'expo-camera' -import * as MediaLibrary from 'expo-media-library/legacy' +import {usePermissions as useMediaLibraryPermissions} from '#/lib/media/media-library' import {Alert} from '#/view/com/util/Alert' import {IS_WEB} from '#/env' @@ -20,7 +20,7 @@ const openPermissionAlert = (perm: string) => { } export function usePhotoLibraryPermission() { - const [res, requestPermission] = MediaLibrary.usePermissions({ + const [res, requestPermission] = useMediaLibraryPermissions({ granularPermissions: ['photo'], }) const requestPhotoAccessIfNeeded = async () => { @@ -49,7 +49,7 @@ export function usePhotoLibraryPermission() { } export function useVideoLibraryPermission() { - const [res, requestPermission] = MediaLibrary.usePermissions({ + const [res, requestPermission] = useMediaLibraryPermissions({ granularPermissions: ['video'], }) const requestVideoAccessIfNeeded = async () => { diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 3977a861e3..71a75ef446 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -13,12 +13,12 @@ import { writeAsStringAsync, } from 'expo-file-system/legacy' import {SaveFormat} from 'expo-image-manipulator' -import * as MediaLibrary from 'expo-media-library/legacy' import * as Sharing from 'expo-sharing' import {logger} from '#/logger' import {IS_ANDROID, IS_IOS} from '#/env' import {renderImage} from './image-manipulator' +import {Album, Asset} from './media-library' import {type PickerImage} from './picker.shared' import {type Dimensions} from './types' import {convertCdnPreset, getResizedDimensions} from './util' @@ -123,52 +123,31 @@ export async function saveImageToMediaLibrary({uri}: {uri: string}) { // android triggers an annoying permission prompt if you try and move an image // between albums. therefore, we need to either create the album with the image // as the starting image, or put it directly into the album - const album = await MediaLibrary.getAlbumAsync(ALBUM_NAME) + const album = await Album.get(ALBUM_NAME) if (album) { - // try and migrate if needed - try { - if (await MediaLibrary.albumNeedsMigrationAsync(album)) { - await MediaLibrary.migrateAlbumIfNeededAsync(album) - } - } catch (err) { - logger.info('Attempted and failed to migrate album', { - safeMessage: err, - }) - } - try { // if album exists, put the image straight in there - await MediaLibrary.createAssetAsync(imagePath, album) + await Asset.create(imagePath, album) } catch (err) { logger.info('Failed to create asset', {safeMessage: err}) // however, it's possible that we don't have write permission to the album // try making a new one! try { - await MediaLibrary.createAlbumAsync( - ALBUM_NAME, - undefined, - undefined, - imagePath, - ) + await Album.create(ALBUM_NAME, [imagePath]) } catch (err2) { logger.info('Failed to create asset in a fresh album', { safeMessage: err2, }) // ... and if all else fails, just put it in DCIM - await MediaLibrary.createAssetAsync(imagePath) + await Asset.create(imagePath) } } } else { // otherwise, create album with asset (albums must always have at least one asset) - await MediaLibrary.createAlbumAsync( - ALBUM_NAME, - undefined, - undefined, - imagePath, - ) + await Album.create(ALBUM_NAME, [imagePath]) } } else { - await MediaLibrary.saveToLibraryAsync(imagePath) + await Asset.create(imagePath) } } catch (err) { logger.error(err instanceof Error ? err : String(err), { diff --git a/src/lib/media/media-library.ts b/src/lib/media/media-library.ts new file mode 100644 index 0000000000..c3029ed590 --- /dev/null +++ b/src/lib/media/media-library.ts @@ -0,0 +1,6 @@ +export { + Album, + Asset, + requestPermissionsAsync, + usePermissions, +} from 'expo-media-library' diff --git a/src/lib/media/media-library.web.ts b/src/lib/media/media-library.web.ts new file mode 100644 index 0000000000..2de0793f08 --- /dev/null +++ b/src/lib/media/media-library.web.ts @@ -0,0 +1,54 @@ +import {type PermissionResponse, PermissionStatus} from 'expo' +import type { + Album as NativeAlbum, + Asset as NativeAsset, + GranularPermission, +} from 'expo-media-library' + +const noPermissionResponse: PermissionResponse = { + status: PermissionStatus.UNDETERMINED, + canAskAgain: true, + granted: false, + expires: 'never', +} + +function unavailable(): Promise { + return Promise.reject(new Error('Media library is unavailable on web')) +} + +export const Album = { + create( + _name: string, + _assetRefs: string[] | NativeAsset[], + ): Promise { + return unavailable() + }, + get(_title: string): Promise { + return unavailable() + }, +} + +export const Asset = { + create(_filePath: string, _album?: NativeAlbum): Promise { + return unavailable() + }, +} + +export function requestPermissionsAsync( + _writeOnly?: boolean, + _granularPermissions?: GranularPermission[], +): Promise { + return Promise.resolve(noPermissionResponse) +} + +export function usePermissions(_options?: { + writeOnly?: boolean + granularPermissions?: GranularPermission[] +}): [ + PermissionResponse | null, + () => Promise, + () => Promise, +] { + const getPermission = () => Promise.resolve(noPermissionResponse) + return [null, getPermission, getPermission] +} diff --git a/src/lib/media/save-image.ts b/src/lib/media/save-image.ts index b841bee754..b75296608b 100644 --- a/src/lib/media/save-image.ts +++ b/src/lib/media/save-image.ts @@ -1,21 +1,22 @@ import {useCallback} from 'react' -import * as MediaLibrary from 'expo-media-library/legacy' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import * as Toast from '#/components/Toast' import {IS_NATIVE} from '#/env' import {saveImageToMediaLibrary} from './manip' +import {usePermissions} from './media-library' /** * Same as `saveImageToMediaLibrary`, but also handles permissions and toasts */ export function useSaveImageToMediaLibrary() { const {_} = useLingui() - const [permissionResponse, requestPermission, getPermission] = - MediaLibrary.usePermissions({ + const [permissionResponse, requestPermission, getPermission] = usePermissions( + { granularPermissions: ['photo'], - }) + }, + ) return useCallback( async (uri: string) => { if (!IS_NATIVE) { diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index 085233ff11..0937b9d795 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -1,8 +1,8 @@ -import * as MediaLibrary from 'expo-media-library/legacy' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {useCameraPermission} from '#/lib/hooks/usePermissions' +import {Asset, usePermissions} from '#/lib/media/media-library' import {openCamera} from '#/lib/media/picker' import {logger} from '#/logger' import {createComposerImage} from '#/state/gallery' @@ -15,8 +15,9 @@ import {type OpenCameraBtnProps} from './OpenCameraBtn.shared' export function OpenCameraBtn({disabled, onAdd}: OpenCameraBtnProps) { const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() - const [mediaPermissionRes, requestMediaPermission] = - MediaLibrary.usePermissions({granularPermissions: ['photo']}) + const [mediaPermissionRes, requestMediaPermission] = usePermissions({ + granularPermissions: ['photo'], + }) const t = useTheme() const mediaGranted = mediaPermissionRes?.granted @@ -32,9 +33,11 @@ export function OpenCameraBtn({disabled, onAdd}: OpenCameraBtnProps) { if (!(await requestCameraAccessIfNeeded())) { return } + let canSaveToMediaLibrary = mediaGranted === true if (!mediaGranted) { if (mediaCanAskAgain) { - await requestMediaPermission() + const permission = await requestMediaPermission() + canSaveToMediaLibrary = permission.granted } } @@ -45,10 +48,14 @@ export function OpenCameraBtn({disabled, onAdd}: OpenCameraBtnProps) { return } - // If we don't have permissions it's fine, we just wont save it. The post itself will still have access to - // the image even without these permissions - if (mediaPermissionRes) { - await MediaLibrary.createAssetAsync(img.path) + if (canSaveToMediaLibrary) { + try { + await Asset.create(img.path) + } catch (err) { + logger.warn('Failed to save camera image to media library', { + safeMessage: err, + }) + } } const res = await createComposerImage(img)