From 243a5bebb7184ade7d6d54f50763c136ee597e57 Mon Sep 17 00:00:00 2001 From: Hailey Date: Sat, 6 Apr 2024 14:32:35 -0700 Subject: [PATCH] migrate to `expo-file-system` --- __tests__/lib/images.test.ts | 146 ++++++++++++++-------- jest/jestSetup.js | 21 ++-- package.json | 3 +- src/lib/api/api-polyfill.ts | 28 ++++- src/lib/api/index.ts | 20 +-- src/lib/media/manip.ts | 233 +++++++++++++++++++++++------------ src/lib/media/picker.e2e.tsx | 31 +++-- yarn.lock | 10 +- 8 files changed, 325 insertions(+), 167 deletions(-) diff --git a/__tests__/lib/images.test.ts b/__tests__/lib/images.test.ts index 38b722e2ce..a0654439bb 100644 --- a/__tests__/lib/images.test.ts +++ b/__tests__/lib/images.test.ts @@ -1,25 +1,29 @@ +import {createDownloadResumable, deleteAsync} from 'expo-file-system' +import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' + import { downloadAndResize, DownloadAndResizeOpts, -} from '../../src/lib/media/manip' -import ImageResizer from '@bam.tech/react-native-image-resizer' -import RNFetchBlob from 'rn-fetch-blob' + getResizedDimensions, +} from 'lib/media/manip' describe('downloadAndResize', () => { const errorSpy = jest.spyOn(global.console, 'error') const mockResizedImage = { - path: jest.fn().mockReturnValue('file://resized-image.jpg'), + path: 'file://resized-image.jpg', size: 100, - width: 50, - height: 50, + width: 100, + height: 100, mime: 'image/jpeg', } beforeEach(() => { - const mockedCreateResizedImage = - ImageResizer.createResizedImage as jest.Mock - mockedCreateResizedImage.mockResolvedValue(mockResizedImage) + const mockedCreateResizedImage = manipulateAsync as jest.Mock + mockedCreateResizedImage.mockResolvedValue({ + uri: 'file://resized-image.jpg', + ...mockResizedImage, + }) }) afterEach(() => { @@ -27,10 +31,12 @@ describe('downloadAndResize', () => { }) it('should return resized image for valid URI and options', async () => { - const mockedFetch = RNFetchBlob.fetch as jest.Mock - mockedFetch.mockResolvedValueOnce({ - path: jest.fn().mockReturnValue('file://downloaded-image.jpg'), - flush: jest.fn(), + const mockedFetch = createDownloadResumable as jest.Mock + mockedFetch.mockReturnValue({ + cancelAsync: jest.fn(), + downloadAsync: jest + .fn() + .mockResolvedValue({uri: 'file://resized-image.jpg'}), }) const opts: DownloadAndResizeOpts = { @@ -44,25 +50,22 @@ describe('downloadAndResize', () => { const result = await downloadAndResize(opts) expect(result).toEqual(mockResizedImage) - expect(RNFetchBlob.config).toHaveBeenCalledWith({ - fileCache: true, - appendExt: 'jpeg', + expect(createDownloadResumable).toHaveBeenCalledWith( + opts.uri, + expect.anything(), + { + cache: true, + }, + ) + expect(manipulateAsync).toHaveBeenCalledWith(expect.anything(), [], { + format: SaveFormat.JPEG, }) - expect(RNFetchBlob.fetch).toHaveBeenCalledWith( - 'GET', - 'https://example.com/image.jpg', - ) - expect(ImageResizer.createResizedImage).toHaveBeenCalledWith( - 'file://downloaded-image.jpg', - 100, - 100, - 'JPEG', - 100, - undefined, - undefined, - undefined, - {mode: 'cover'}, + expect(manipulateAsync).toHaveBeenCalledWith( + expect.anything(), + [{resize: {height: opts.height, width: opts.width}}], + {format: SaveFormat.JPEG, compress: 0.9}, ) + expect(deleteAsync).toHaveBeenCalledWith(expect.anything()) }) it('should return undefined for invalid URI', async () => { @@ -81,10 +84,12 @@ describe('downloadAndResize', () => { }) it('should return undefined for unsupported file type', async () => { - const mockedFetch = RNFetchBlob.fetch as jest.Mock - mockedFetch.mockResolvedValueOnce({ - path: jest.fn().mockReturnValue('file://downloaded-image'), - flush: jest.fn(), + const mockedFetch = createDownloadResumable as jest.Mock + mockedFetch.mockReturnValue({ + cancelAsync: jest.fn(), + downloadAsync: jest + .fn() + .mockResolvedValue({uri: 'file://downloaded-image'}), }) const opts: DownloadAndResizeOpts = { @@ -98,24 +103,63 @@ describe('downloadAndResize', () => { const result = await downloadAndResize(opts) expect(result).toEqual(mockResizedImage) - expect(RNFetchBlob.config).toHaveBeenCalledWith({ - fileCache: true, - appendExt: 'jpeg', + expect(createDownloadResumable).toHaveBeenCalledWith( + opts.uri, + expect.anything(), + { + cache: true, + }, + ) + expect(manipulateAsync).toHaveBeenCalledWith(expect.anything(), [], { + format: SaveFormat.JPEG, }) - expect(RNFetchBlob.fetch).toHaveBeenCalledWith( - 'GET', - 'https://example.com/image', - ) - expect(ImageResizer.createResizedImage).toHaveBeenCalledWith( - 'file://downloaded-image', - 100, - 100, - 'JPEG', - 100, - undefined, - undefined, - undefined, - {mode: 'cover'}, + expect(manipulateAsync).toHaveBeenCalledWith( + expect.anything(), + [{resize: {height: opts.height, width: opts.width}}], + {format: SaveFormat.JPEG, compress: 0.9}, ) + expect(deleteAsync).toHaveBeenCalledWith(expect.anything()) + }) +}) + +describe('produces correct new sizes for images', () => { + it('should not downsize whenever dimensions are below the max dimensions', () => { + const initialDimensionsOne = { + width: 1200, + height: 1000, + } + const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) + + const initialDimensionsTwo = { + width: 1000, + height: 1200, + } + const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) + + expect(resizedDimensionsOne).toEqual(initialDimensionsOne) + expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo) + }) + + it('should resize dimensions and maintain aspect ratio if they are above the max dimensons', () => { + const initialDimensionsOne = { + width: 3000, + height: 1500, + } + const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) + + const initialDimensionsTwo = { + width: 2000, + height: 4000, + } + const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) + + expect(resizedDimensionsOne).toEqual({ + width: 2000, + height: 1000, + }) + expect(resizedDimensionsTwo).toEqual({ + width: 1000, + height: 2000, + }) }) }) diff --git a/jest/jestSetup.js b/jest/jestSetup.js index e690e813a9..78ce23b97c 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -1,10 +1,10 @@ /* global jest */ -import {configure} from '@testing-library/react-native' import 'react-native-gesture-handler/jestSetup' - // IMPORTANT: this is what's used in the native runtime import 'react-native-url-polyfill/auto' +import {configure} from '@testing-library/react-native' + configure({asyncUtilTimeout: 20000}) jest.mock('@react-native-async-storage/async-storage', () => @@ -36,14 +36,19 @@ jest.mock('react-native-safe-area-context', () => { } }) -jest.mock('rn-fetch-blob', () => ({ - config: jest.fn().mockReturnThis(), - cancel: jest.fn(), - fetch: jest.fn(), +jest.mock('expo-file-system', () => ({ + createDownloadResumable: jest.fn(), + deleteAsync: jest.fn(), + getInfoAsync: jest.fn().mockResolvedValue({ + size: 100, + }), })) -jest.mock('@bam.tech/react-native-image-resizer', () => ({ - createResizedImage: jest.fn(), +jest.mock('expo-image-manipulator', () => ({ + manipulateAsync: jest.fn().mockResolvedValue({ + uri: 'file://resized-image', + }), + SaveFormat: jest.requireActual('expo-image-manipulator').SaveFormat, })) jest.mock('@segment/analytics-react-native', () => ({ diff --git a/package.json b/package.json index f61d36a6b0..0907383aab 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "expo-constants": "~15.4.5", "expo-dev-client": "~3.3.8", "expo-device": "~5.9.3", + "expo-file-system": "^16.0.8", "expo-haptics": "^12.8.1", "expo-image": "~1.10.6", "expo-image-manipulator": "^11.8.0", @@ -158,7 +159,6 @@ "react-native": "0.73.2", "react-native-date-picker": "^4.4.0", "react-native-drawer-layout": "^4.0.0-alpha.3", - "react-native-fs": "^2.20.0", "react-native-gesture-handler": "~2.14.0", "react-native-get-random-values": "~1.11.0", "react-native-image-crop-picker": "^0.38.1", @@ -178,7 +178,6 @@ "react-native-web-webview": "^1.0.2", "react-native-webview": "13.6.4", "react-responsive": "^9.0.2", - "rn-fetch-blob": "^0.12.0", "sentry-expo": "~7.0.1", "statsig-react-native-expo": "^4.6.1", "tippy.js": "^6.3.7", diff --git a/src/lib/api/api-polyfill.ts b/src/lib/api/api-polyfill.ts index ea1d975985..c1a7ced692 100644 --- a/src/lib/api/api-polyfill.ts +++ b/src/lib/api/api-polyfill.ts @@ -1,5 +1,5 @@ -import {BskyAgent, stringifyLex, jsonToLex} from '@atproto/api' -import RNFS from 'react-native-fs' +import {cacheDirectory, copyAsync, moveAsync} from 'expo-file-system' +import {BskyAgent, jsonToLex, stringifyLex} from '@atproto/api' const GET_TIMEOUT = 15e3 // 15s const POST_TIMEOUT = 60e3 // 60s @@ -33,9 +33,27 @@ async function fetchHandler( // we get around that by renaming the file ext to .bin // see https://github.com/facebook/react-native/issues/27099 // -prf - const newPath = reqBody.replace(/\.jpe?g$/, '.bin') - await RNFS.moveFile(reqBody, newPath) - reqBody = newPath + + // On some platforms, moving this file is not possible. We will attempt to move it (this is optimal, since + // we don't create duplicates) and if there is an error, we will instead copy the file to the cache directory + const fileName = reqBody.split('/').pop() ?? '' + const newPath = `${cacheDirectory ?? ''}${fileName.replace( + /\.jpe?g$/, + '.bin', + )}` + try { + await moveAsync({ + from: reqBody, + to: newPath, + }) + reqBody = newPath + } catch (e) { + await copyAsync({ + from: reqBody, + to: newPath, + }) + reqBody = newPath + } } // NOTE // React native treats bodies with {uri: string} as file uploads to pull from cache diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 5fb7fe50e6..c506264618 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,6 +1,7 @@ +import {deleteAsync} from 'expo-file-system' import { - AppBskyEmbedImages, AppBskyEmbedExternal, + AppBskyEmbedImages, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, AppBskyFeedThreadgate, @@ -11,13 +12,14 @@ import { RichText, } from '@atproto/api' import {AtUri} from '@atproto/api' -import {isNetworkError} from 'lib/strings/errors' -import {LinkMeta} from '../link-meta/link-meta' -import {isWeb} from 'platform/detection' -import {ImageModel} from 'state/models/media/image' -import {shortenLinks} from 'lib/strings/rich-text-manip' + import {logger} from '#/logger' import {ThreadgateSetting} from '#/state/queries/threadgate' +import {isNetworkError} from 'lib/strings/errors' +import {shortenLinks} from 'lib/strings/rich-text-manip' +import {isWeb} from 'platform/detection' +import {ImageModel} from 'state/models/media/image' +import {LinkMeta} from '../link-meta/link-meta' export interface ExternalEmbedDraft { uri: string @@ -39,10 +41,14 @@ export async function uploadBlob( }) } else { // `blob` should be a path to a file in the local FS - return agent.uploadBlob( + const res = await agent.uploadBlob( blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts {encoding}, ) + try { + deleteAsync(blob) + } catch (e) {} // Don't need to handle + return res } } diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index a681627e67..1b4836b26e 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -1,13 +1,21 @@ -import RNFetchBlob from 'rn-fetch-blob' -import ImageResizer from '@bam.tech/react-native-image-resizer' -import {Image as RNImage, Share as RNShare} from 'react-native' +import {Image as RNImage} from 'react-native' import {Image} from 'react-native-image-crop-picker' -import * as RNFS from 'react-native-fs' import uuid from 'react-native-uuid' -import * as Sharing from 'expo-sharing' +import { + cacheDirectory, + copyAsync, + createDownloadResumable, + deleteAsync, + FileInfo, + getInfoAsync, +} from 'expo-file-system' +import {Image as ExpoImage} from 'expo-image' +import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' import * as MediaLibrary from 'expo-media-library' +import * as Sharing from 'expo-sharing' + +import {POST_IMG_MAX} from 'lib/constants' import {Dimensions} from './types' -import {isAndroid, isIOS} from 'platform/detection' export async function compressIfNeeded( img: Image, @@ -53,26 +61,13 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) { return } - let downloadRes + const path = createPath(appendExt) + try { - const downloadResPromise = RNFetchBlob.config({ - fileCache: true, - appendExt, - }).fetch('GET', opts.uri) - const to1 = setTimeout(() => downloadResPromise.cancel(), opts.timeout) - downloadRes = await downloadResPromise - clearTimeout(to1) - - let localUri = downloadRes.path() - if (!localUri.startsWith('file://')) { - localUri = `file://${localUri}` - } - - return await doResize(localUri, opts) + await downloadImage(opts.uri, path, opts.timeout) + return await doResize(path, opts) } finally { - if (downloadRes) { - downloadRes.flush() - } + deleteAsync(path) } } @@ -81,47 +76,45 @@ export async function shareImageModal({uri}: {uri: string}) { // TODO might need to give an error to the user in this case -prf return } - const downloadResponse = await RNFetchBlob.config({ - fileCache: true, - }).fetch('GET', uri) - // NOTE - // assuming PNG - // we're currently relying on the fact our CDN only serves pngs - // -prf - - let imagePath = downloadResponse.path() - imagePath = normalizePath(await moveToPermanentPath(imagePath, '.png'), true) - - // NOTE - // for some reason expo-sharing refuses to work on iOS - // ...and visa versa - // -prf - if (isIOS) { - await RNShare.share({url: imagePath}) - } else { - await Sharing.shareAsync(imagePath, { - mimeType: 'image/png', - UTI: 'image/png', - }) + // Usually whenever we share an image it will already be available in the cache. If it isn't, then we + // will download it. + let imageUri = await ExpoImage.getCachePathAsync(uri) + if (!imageUri) { + // NOTE + // assuming PNG + // we're currently relying on the fact our CDN only serves pngs + // -prf + imageUri = await downloadImage(uri, createPath('png'), 5e3) } - RNFS.unlink(imagePath) + + const imagePath = await moveToPermanentPath(imageUri, '.png') + + await Sharing.shareAsync(imagePath, { + mimeType: 'image/png', + UTI: 'image/png', + }) + + deleteAsync(imagePath) } export async function saveImageToMediaLibrary({uri}: {uri: string}) { - // download the file to cache - // NOTE - // assuming PNG - // we're currently relying on the fact our CDN only serves pngs - // -prf - const downloadResponse = await RNFetchBlob.config({ - fileCache: true, - }).fetch('GET', uri) - let imagePath = downloadResponse.path() - imagePath = normalizePath(await moveToPermanentPath(imagePath, '.png'), true) + let imageUri = await ExpoImage.getCachePathAsync(uri) + if (!imageUri) { + // download the file to cache + // NOTE + // assuming PNG + // we're currently relying on the fact our CDN only serves pngs + // -prf + imageUri = await downloadImage(uri, createPath('png'), 5e3) + } + + const imagePath = await moveToPermanentPath(imageUri, '.png') // save await MediaLibrary.createAssetAsync(imagePath) + + deleteAsync(imagePath) } export function getImageDim(path: string): Promise { @@ -147,27 +140,48 @@ interface DoResizeOpts { } async function doResize(localUri: string, opts: DoResizeOpts): Promise { + // This is a bit of a hack, but it lets us get the original size of the image. The old image manipulation library + // allowed us to supply a max height/width and it would handle the resizing. With expo-image-manipulator, we have + // to supply the exact size and width that we want to resize to instead. We will calculate that ourselves based on + // the height/width results of this first manipulation + const imageRes = await manipulateAsync(localUri, [], { + format: SaveFormat.JPEG, + }) + + const newDimensions = getResizedDimensions({ + width: imageRes.width, + height: imageRes.height, + }) + for (let i = 0; i < 9; i++) { - const quality = 100 - i * 10 - const resizeRes = await ImageResizer.createResizedImage( + const quality = 0.9 - 0.1 * i + const resizeRes = await manipulateAsync( localUri, - opts.width, - opts.height, - 'JPEG', - quality, - undefined, - undefined, - undefined, - {mode: opts.mode}, + [{resize: {height: newDimensions.height, width: newDimensions.width}}], + { + format: SaveFormat.JPEG, + compress: quality, + }, ) - if (resizeRes.size < opts.maxSize) { + + // @ts-ignore This is valid, `getInfoAsync` will always return a size. The type is wonky + const info: FileInfo & {size: number} = await getInfoAsync(resizeRes.uri, { + size: true, + }) + + // We want to clean up every resize _except_ the final result. We'll clean that one up later when we're finished + // with it + if (info.size < opts.maxSize) { + await deleteAsync(imageRes.uri) return { - path: normalizePath(resizeRes.path), + path: normalizePath(resizeRes.uri), mime: 'image/jpeg', - size: resizeRes.size, + size: info.size, width: resizeRes.width, height: resizeRes.height, } + } else { + await deleteAsync(resizeRes.uri) } } throw new Error( @@ -182,12 +196,29 @@ async function moveToPermanentPath(path: string, ext = ''): Promise { https://github.com/ivpusic/react-native-image-crop-picker/issues/1199 */ const filename = uuid.v4() + const destinationPath = joinPath(cacheDirectory ?? '', `${filename}${ext}`) + + await copyAsync({ + from: normalizePath(path), + to: destinationPath, + }) + + // This is just to try and clean up whenever we can. We won't always be able to, so in cases where we can't + // we just catch the error. We can't simply move some files though such as image caches, so we will copy then + // and attempt to clean up the original file + // Paths that are image caches shouldn't be removed. com.hackemist.SDImageCache is used by SDWebImage and + // image_manager_disk_cache is used by Glide + if ( + !path.includes('com.hackemist.SDImageCache') && + !path.includes('image_manager_disk_cache') + ) { + try { + deleteAsync(path) + } catch (e) { + // No need to handle + } + } - const destinationPath = joinPath( - RNFS.TemporaryDirectoryPath, - `${filename}${ext}`, - ) - await RNFS.moveFile(path, destinationPath) return normalizePath(destinationPath) } @@ -203,11 +234,53 @@ function joinPath(a: string, b: string) { return a + '/' + b } -function normalizePath(str: string, allPlatforms = false): string { - if (isAndroid || allPlatforms) { - if (!str.startsWith('file://')) { - return `file://${str}` - } +function normalizePath(str: string): string { + if (!str.startsWith('file://')) { + return `file://${str}` } return str } + +function createPath(ext: string) { + // cacheDirectory will never be null on native, so the null check here is not necessary except for typescript. + // we use a web-only function for downloadAndResize on web + return `${cacheDirectory ?? ''}/${uuid.v4()}.${ext}` +} + +async function downloadImage(uri: string, path: string, timeout: number) { + const downloadResumable = createDownloadResumable(uri, path, { + cache: true, + }) + + const to1 = setTimeout(() => downloadResumable.cancelAsync(), timeout) + const downloadRes = await downloadResumable.downloadAsync() + clearTimeout(to1) + + if (!downloadRes?.uri) { + throw new Error() + } + + return normalizePath(downloadRes.uri) +} + +export function getResizedDimensions(originalDims: { + width: number + height: number +}) { + if ( + originalDims.width <= POST_IMG_MAX.width && + originalDims.height <= POST_IMG_MAX.height + ) { + return originalDims + } + + const ratio = Math.min( + POST_IMG_MAX.width / originalDims.width, + POST_IMG_MAX.height / originalDims.height, + ) + + return { + width: Math.round(originalDims.width * ratio), + height: Math.round(originalDims.height * ratio), + } +} diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx index 31702ab227..f5ca496c60 100644 --- a/src/lib/media/picker.e2e.tsx +++ b/src/lib/media/picker.e2e.tsx @@ -1,21 +1,34 @@ import {Image as RNImage} from 'react-native-image-crop-picker' -import RNFS from 'react-native-fs' -import {CropperOptions} from './types' -import {compressIfNeeded} from './manip' +import { + documentDirectory, + getInfoAsync, + readDirectoryAsync, +} from 'expo-file-system' +import {compressIfNeeded} from './manip' +import {CropperOptions} from './types' + +let _imageCounter = 0 async function getFile() { - let files = await RNFS.readDir( - RNFS.LibraryDirectoryPath.split('/') + // This *should* work. In RNFS, there was a LibraryDirectoryPath constant, which is not present in + // expo-file-system. This should work as a work around at least on simulators (probably elsewhere + // too though). Since this is only used for e2e though, this should be safe. + const libraryDirPath = documentDirectory?.split('/data/')[0] + '/data' + + let paths = await readDirectoryAsync( + libraryDirPath + .split('/') .slice(0, -5) .concat(['Media', 'DCIM', '100APPLE']) .join('/'), ) - files = files.filter(file => file.path.endsWith('.JPG')) - const file = files[0] + paths = paths.filter(path => path.endsWith('.JPG')) + const path = paths[_imageCounter++ % paths.length] return await compressIfNeeded({ - path: file.path, + path, mime: 'image/jpeg', - size: file.size, + // @ts-ignore Size is available here, type is incorrect + size: (await getInfoAsync(path, {size: true})).size ?? 0, width: 4288, height: 2848, }) diff --git a/yarn.lock b/yarn.lock index 323ac3c106..a367029e85 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11860,16 +11860,16 @@ expo-eas-client@~0.11.0: resolved "https://registry.yarnpkg.com/expo-eas-client/-/expo-eas-client-0.11.0.tgz#0f25aa497849cade7ebef55c0631093a87e58b07" integrity sha512-99W0MUGe3U4/MY1E9UeJ4uKNI39mN8/sOGA0Le8XC47MTbwbLoVegHR3C5y2fXLwLn7EpfNxAn5nlxYjY3gD2A== +expo-file-system@^16.0.8, expo-file-system@~16.0.8: + version "16.0.8" + resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-16.0.8.tgz#13c79a8e06e42a8e76e9297df6920597a011d989" + integrity sha512-yDbVT0TUKd7ewQjaY5THum2VRFx2n/biskGhkUmLh3ai21xjIVtaeIzHXyv9ir537eVgt4ReqDNWi7jcXjdUcA== + expo-file-system@~16.0.0: version "16.0.1" resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-16.0.1.tgz#326b7c2f6e53e1a0eaafc9769578aafb3f9c9f43" integrity sha512-/U6ufN2wRPgg4m2a9sqbL3dThqQsysT022qulEXWnUTmNaqnzYSk9ihjDWqoqjXLi9slQLsyok5t6CNzhM7HPw== -expo-file-system@~16.0.8: - version "16.0.8" - resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-16.0.8.tgz#13c79a8e06e42a8e76e9297df6920597a011d989" - integrity sha512-yDbVT0TUKd7ewQjaY5THum2VRFx2n/biskGhkUmLh3ai21xjIVtaeIzHXyv9ir537eVgt4ReqDNWi7jcXjdUcA== - expo-font@~11.10.3: version "11.10.3" resolved "https://registry.yarnpkg.com/expo-font/-/expo-font-11.10.3.tgz#a3115ebda8e09bd7cb8052619a4bbe606f0c17f4"