migrate media library api
This commit is contained in:
+7
-9
@@ -75,18 +75,16 @@ jest.mock('expo-camera', () => ({
|
|||||||
|
|
||||||
jest.mock('expo-media-library', () => ({
|
jest.mock('expo-media-library', () => ({
|
||||||
__esModule: true, // this property makes it work
|
__esModule: true, // this property makes it work
|
||||||
|
Album: {
|
||||||
|
create: jest.fn(),
|
||||||
|
get: jest.fn(),
|
||||||
|
},
|
||||||
|
Asset: {
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
default: jest.fn(),
|
default: jest.fn(),
|
||||||
usePermissions: jest.fn(() => [true]),
|
usePermissions: jest.fn(() => [true]),
|
||||||
requestPermissionsAsync: jest.fn().mockResolvedValue({granted: 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', () => ({
|
jest.mock('@bsky.app/expo-guess-language', () => ({
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import {Suspense, useRef, useState} from 'react'
|
import {Suspense, useRef, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {type ViewShotRef} from 'react-native-view-shot'
|
import {type ViewShotRef} from 'react-native-view-shot'
|
||||||
import {
|
|
||||||
requestPermissionsAsync,
|
|
||||||
saveToLibraryAsync,
|
|
||||||
} from 'expo-media-library/legacy'
|
|
||||||
import * as Sharing from 'expo-sharing'
|
import * as Sharing from 'expo-sharing'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {Trans} from '@lingui/react/macro'
|
import {Trans} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {Asset, requestPermissionsAsync} from '#/lib/media/media-library'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {atoms as a, useBreakpoints} from '#/alf'
|
import {atoms as a, useBreakpoints} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
@@ -77,9 +74,8 @@ export function QrCodeDialog({
|
|||||||
|
|
||||||
// Incase of a FS failure, don't crash the app
|
// Incase of a FS failure, don't crash the app
|
||||||
try {
|
try {
|
||||||
// saveToLibraryAsync writes without reading the asset back, so it
|
// Asset.create supports the add-only permission on iOS (APP-2374).
|
||||||
// works with the add-only permission on iOS (APP-2374)
|
await Asset.create(`file://${uri}`)
|
||||||
await saveToLibraryAsync(`file://${uri}`)
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
Toast.show(_(msg`An error occurred while saving the QR code!`), {
|
Toast.show(_(msg`An error occurred while saving the QR code!`), {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
|||||||
@@ -2,13 +2,10 @@ import {Suspense, useRef} from 'react'
|
|||||||
import {Pressable, View} from 'react-native'
|
import {Pressable, View} from 'react-native'
|
||||||
import {type ViewShotRef} from 'react-native-view-shot'
|
import {type ViewShotRef} from 'react-native-view-shot'
|
||||||
import {setStringAsync} from 'expo-clipboard'
|
import {setStringAsync} from 'expo-clipboard'
|
||||||
import {
|
|
||||||
requestPermissionsAsync,
|
|
||||||
saveToLibraryAsync,
|
|
||||||
} from 'expo-media-library/legacy'
|
|
||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {Asset, requestPermissionsAsync} from '#/lib/media/media-library'
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {shareUrl as nativeShareUrl} from '#/lib/sharing'
|
import {shareUrl as nativeShareUrl} from '#/lib/sharing'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
@@ -96,10 +93,8 @@ export function InviteFriendsDialogInner({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// saveToLibraryAsync writes without reading the asset back, so it works
|
// Asset.create supports the add-only permission on iOS (APP-2374).
|
||||||
// with the add-only permission. createAssetAsync fetches the created
|
await Asset.create(`file://${uri}`)
|
||||||
// asset, which triggers the full library read prompt on iOS (APP-2374).
|
|
||||||
await saveToLibraryAsync(`file://${uri}`)
|
|
||||||
ax.metric('invite:action:download', {})
|
ax.metric('invite:action:download', {})
|
||||||
Toast.show(l`QR code saved to your camera roll!`)
|
Toast.show(l`QR code saved to your camera roll!`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {Linking} from 'react-native'
|
import {Linking} from 'react-native'
|
||||||
import {useCameraPermissions as useExpoCameraPermissions} from 'expo-camera'
|
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 {Alert} from '#/view/com/util/Alert'
|
||||||
import {IS_WEB} from '#/env'
|
import {IS_WEB} from '#/env'
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ const openPermissionAlert = (perm: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePhotoLibraryPermission() {
|
export function usePhotoLibraryPermission() {
|
||||||
const [res, requestPermission] = MediaLibrary.usePermissions({
|
const [res, requestPermission] = useMediaLibraryPermissions({
|
||||||
granularPermissions: ['photo'],
|
granularPermissions: ['photo'],
|
||||||
})
|
})
|
||||||
const requestPhotoAccessIfNeeded = async () => {
|
const requestPhotoAccessIfNeeded = async () => {
|
||||||
@@ -49,7 +49,7 @@ export function usePhotoLibraryPermission() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useVideoLibraryPermission() {
|
export function useVideoLibraryPermission() {
|
||||||
const [res, requestPermission] = MediaLibrary.usePermissions({
|
const [res, requestPermission] = useMediaLibraryPermissions({
|
||||||
granularPermissions: ['video'],
|
granularPermissions: ['video'],
|
||||||
})
|
})
|
||||||
const requestVideoAccessIfNeeded = async () => {
|
const requestVideoAccessIfNeeded = async () => {
|
||||||
|
|||||||
+7
-28
@@ -13,12 +13,12 @@ import {
|
|||||||
writeAsStringAsync,
|
writeAsStringAsync,
|
||||||
} from 'expo-file-system/legacy'
|
} from 'expo-file-system/legacy'
|
||||||
import {SaveFormat} from 'expo-image-manipulator'
|
import {SaveFormat} from 'expo-image-manipulator'
|
||||||
import * as MediaLibrary from 'expo-media-library/legacy'
|
|
||||||
import * as Sharing from 'expo-sharing'
|
import * as Sharing from 'expo-sharing'
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {IS_ANDROID, IS_IOS} from '#/env'
|
import {IS_ANDROID, IS_IOS} from '#/env'
|
||||||
import {renderImage} from './image-manipulator'
|
import {renderImage} from './image-manipulator'
|
||||||
|
import {Album, Asset} from './media-library'
|
||||||
import {type PickerImage} from './picker.shared'
|
import {type PickerImage} from './picker.shared'
|
||||||
import {type Dimensions} from './types'
|
import {type Dimensions} from './types'
|
||||||
import {convertCdnPreset, getResizedDimensions} from './util'
|
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
|
// 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
|
// between albums. therefore, we need to either create the album with the image
|
||||||
// as the starting image, or put it directly into the album
|
// 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) {
|
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 {
|
try {
|
||||||
// if album exists, put the image straight in there
|
// if album exists, put the image straight in there
|
||||||
await MediaLibrary.createAssetAsync(imagePath, album)
|
await Asset.create(imagePath, album)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.info('Failed to create asset', {safeMessage: err})
|
logger.info('Failed to create asset', {safeMessage: err})
|
||||||
// however, it's possible that we don't have write permission to the album
|
// however, it's possible that we don't have write permission to the album
|
||||||
// try making a new one!
|
// try making a new one!
|
||||||
try {
|
try {
|
||||||
await MediaLibrary.createAlbumAsync(
|
await Album.create(ALBUM_NAME, [imagePath])
|
||||||
ALBUM_NAME,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
imagePath,
|
|
||||||
)
|
|
||||||
} catch (err2) {
|
} catch (err2) {
|
||||||
logger.info('Failed to create asset in a fresh album', {
|
logger.info('Failed to create asset in a fresh album', {
|
||||||
safeMessage: err2,
|
safeMessage: err2,
|
||||||
})
|
})
|
||||||
// ... and if all else fails, just put it in DCIM
|
// ... and if all else fails, just put it in DCIM
|
||||||
await MediaLibrary.createAssetAsync(imagePath)
|
await Asset.create(imagePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// otherwise, create album with asset (albums must always have at least one asset)
|
// otherwise, create album with asset (albums must always have at least one asset)
|
||||||
await MediaLibrary.createAlbumAsync(
|
await Album.create(ALBUM_NAME, [imagePath])
|
||||||
ALBUM_NAME,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
imagePath,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await MediaLibrary.saveToLibraryAsync(imagePath)
|
await Asset.create(imagePath)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(err instanceof Error ? err : String(err), {
|
logger.error(err instanceof Error ? err : String(err), {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
Album,
|
||||||
|
Asset,
|
||||||
|
requestPermissionsAsync,
|
||||||
|
usePermissions,
|
||||||
|
} from 'expo-media-library'
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
import {useCallback} from 'react'
|
import {useCallback} from 'react'
|
||||||
import * as MediaLibrary from 'expo-media-library/legacy'
|
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
import {IS_NATIVE} from '#/env'
|
import {IS_NATIVE} from '#/env'
|
||||||
import {saveImageToMediaLibrary} from './manip'
|
import {saveImageToMediaLibrary} from './manip'
|
||||||
|
import {usePermissions} from './media-library'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same as `saveImageToMediaLibrary`, but also handles permissions and toasts
|
* Same as `saveImageToMediaLibrary`, but also handles permissions and toasts
|
||||||
*/
|
*/
|
||||||
export function useSaveImageToMediaLibrary() {
|
export function useSaveImageToMediaLibrary() {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const [permissionResponse, requestPermission, getPermission] =
|
const [permissionResponse, requestPermission, getPermission] = usePermissions(
|
||||||
MediaLibrary.usePermissions({
|
{
|
||||||
granularPermissions: ['photo'],
|
granularPermissions: ['photo'],
|
||||||
})
|
},
|
||||||
|
)
|
||||||
return useCallback(
|
return useCallback(
|
||||||
async (uri: string) => {
|
async (uri: string) => {
|
||||||
if (!IS_NATIVE) {
|
if (!IS_NATIVE) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as MediaLibrary from 'expo-media-library/legacy'
|
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {useCameraPermission} from '#/lib/hooks/usePermissions'
|
import {useCameraPermission} from '#/lib/hooks/usePermissions'
|
||||||
|
import {Asset, usePermissions} from '#/lib/media/media-library'
|
||||||
import {openCamera} from '#/lib/media/picker'
|
import {openCamera} from '#/lib/media/picker'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {createComposerImage} from '#/state/gallery'
|
import {createComposerImage} from '#/state/gallery'
|
||||||
@@ -15,8 +15,9 @@ import {type OpenCameraBtnProps} from './OpenCameraBtn.shared'
|
|||||||
export function OpenCameraBtn({disabled, onAdd}: OpenCameraBtnProps) {
|
export function OpenCameraBtn({disabled, onAdd}: OpenCameraBtnProps) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||||
const [mediaPermissionRes, requestMediaPermission] =
|
const [mediaPermissionRes, requestMediaPermission] = usePermissions({
|
||||||
MediaLibrary.usePermissions({granularPermissions: ['photo']})
|
granularPermissions: ['photo'],
|
||||||
|
})
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const mediaGranted = mediaPermissionRes?.granted
|
const mediaGranted = mediaPermissionRes?.granted
|
||||||
@@ -32,9 +33,11 @@ export function OpenCameraBtn({disabled, onAdd}: OpenCameraBtnProps) {
|
|||||||
if (!(await requestCameraAccessIfNeeded())) {
|
if (!(await requestCameraAccessIfNeeded())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let canSaveToMediaLibrary = mediaGranted === true
|
||||||
if (!mediaGranted) {
|
if (!mediaGranted) {
|
||||||
if (mediaCanAskAgain) {
|
if (mediaCanAskAgain) {
|
||||||
await requestMediaPermission()
|
const permission = await requestMediaPermission()
|
||||||
|
canSaveToMediaLibrary = permission.granted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,10 +48,14 @@ export function OpenCameraBtn({disabled, onAdd}: OpenCameraBtnProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we don't have permissions it's fine, we just wont save it. The post itself will still have access to
|
if (canSaveToMediaLibrary) {
|
||||||
// the image even without these permissions
|
try {
|
||||||
if (mediaPermissionRes) {
|
await Asset.create(img.path)
|
||||||
await MediaLibrary.createAssetAsync(img.path)
|
} catch (err) {
|
||||||
|
logger.warn('Failed to save camera image to media library', {
|
||||||
|
safeMessage: err,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await createComposerImage(img)
|
const res = await createComposerImage(img)
|
||||||
|
|||||||
Reference in New Issue
Block a user