migrate to expo-file-system

This commit is contained in:
Hailey
2024-04-06 14:32:35 -07:00
parent a6babaceaf
commit 243a5bebb7
8 changed files with 325 additions and 167 deletions
+95 -51
View File
@@ -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,
})
})
})
+13 -8
View File
@@ -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', () => ({
+1 -2
View File
@@ -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",
+23 -5
View File
@@ -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
+13 -7
View File
@@ -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
}
}
+153 -80
View File
@@ -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<Dimensions> {
@@ -147,27 +140,48 @@ interface DoResizeOpts {
}
async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> {
// 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<string> {
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),
}
}
+22 -9
View File
@@ -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,
})
+5 -5
View File
@@ -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"