migrate media library api

This commit is contained in:
Samuel Newman
2026-09-04 14:31:10 +03:00
parent a197340bce
commit 83d27285e1
9 changed files with 103 additions and 67 deletions
+7 -9
View File
@@ -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', () => ({
+3 -7
View File
@@ -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',
@@ -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) {
+3 -3
View File
@@ -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 () => {
+7 -28
View File
@@ -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), {
+6
View File
@@ -0,0 +1,6 @@
export {
Album,
Asset,
requestPermissionsAsync,
usePermissions,
} from 'expo-media-library'
+54
View File
@@ -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<never> {
return Promise.reject(new Error('Media library is unavailable on web'))
}
export const Album = {
create(
_name: string,
_assetRefs: string[] | NativeAsset[],
): Promise<NativeAlbum> {
return unavailable()
},
get(_title: string): Promise<NativeAlbum | null> {
return unavailable()
},
}
export const Asset = {
create(_filePath: string, _album?: NativeAlbum): Promise<NativeAsset> {
return unavailable()
},
}
export function requestPermissionsAsync(
_writeOnly?: boolean,
_granularPermissions?: GranularPermission[],
): Promise<PermissionResponse> {
return Promise.resolve(noPermissionResponse)
}
export function usePermissions(_options?: {
writeOnly?: boolean
granularPermissions?: GranularPermission[]
}): [
PermissionResponse | null,
() => Promise<PermissionResponse>,
() => Promise<PermissionResponse>,
] {
const getPermission = () => Promise.resolve(noPermissionResponse)
return [null, getPermission, getPermission]
}
+5 -4
View File
@@ -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) {
+15 -8
View File
@@ -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)