Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate to expo-file-system #3437

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 95 additions & 51 deletions __tests__/lib/images.test.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,42 @@
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(() => {
jest.clearAllMocks()
})

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 = {
Expand All @@ -44,25 +50,22 @@ describe('downloadAndResize', () => {

const result = await downloadAndResize(opts)
expect(result).toEqual(mockResizedImage)
expect(RNFetchBlob.config).toHaveBeenCalledWith({
fileCache: true,
appendExt: 'jpeg',
})
expect(RNFetchBlob.fetch).toHaveBeenCalledWith(
'GET',
'https://example.com/image.jpg',
expect(createDownloadResumable).toHaveBeenCalledWith(
opts.uri,
expect.anything(),
{
cache: true,
},
)
expect(ImageResizer.createResizedImage).toHaveBeenCalledWith(
'file://downloaded-image.jpg',
100,
100,
'JPEG',
100,
undefined,
undefined,
undefined,
{mode: 'cover'},
expect(manipulateAsync).toHaveBeenCalledWith(expect.anything(), [], {
format: SaveFormat.JPEG,
})
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 () => {
Expand All @@ -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 = {
Expand All @@ -98,24 +103,63 @@ describe('downloadAndResize', () => {

const result = await downloadAndResize(opts)
expect(result).toEqual(mockResizedImage)
expect(RNFetchBlob.config).toHaveBeenCalledWith({
fileCache: true,
appendExt: 'jpeg',
})
expect(RNFetchBlob.fetch).toHaveBeenCalledWith(
'GET',
'https://example.com/image',
expect(createDownloadResumable).toHaveBeenCalledWith(
opts.uri,
expect.anything(),
{
cache: true,
},
)
expect(ImageResizer.createResizedImage).toHaveBeenCalledWith(
'file://downloaded-image',
100,
100,
'JPEG',
100,
undefined,
undefined,
undefined,
{mode: 'cover'},
expect(manipulateAsync).toHaveBeenCalledWith(expect.anything(), [], {
format: SaveFormat.JPEG,
})
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,
})
})
})
21 changes: 13 additions & 8 deletions jest/jestSetup.js
Original file line number Diff line number Diff line change
@@ -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', () =>
Expand Down Expand Up @@ -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', () => ({
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
28 changes: 23 additions & 5 deletions src/lib/api/api-polyfill.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Comment on lines -36 to -38
Copy link
Contributor Author

@haileyok haileyok Apr 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if we revert, this isn't a good move. From testing, some of these files could not be moved and would result in an error. We need to try/catch this and fallback to a copy if necessary.

// 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
Expand Down
18 changes: 12 additions & 6 deletions src/lib/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {deleteAsync} from 'expo-file-system'
import {
AppBskyEmbedImages,
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedThreadgate,
Expand All @@ -11,13 +12,14 @@ import {
RichText,
} from '@atproto/api'
import {AtUri} from '@atproto/api'

import {logger} from '#/logger'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {isNetworkError} from 'lib/strings/errors'
import {LinkMeta} from '../link-meta/link-meta'
import {shortenLinks} from 'lib/strings/rich-text-manip'
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 {LinkMeta} from '../link-meta/link-meta'

export interface ExternalEmbedDraft {
uri: string
Expand All @@ -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
Comment on lines +49 to +52
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No logical change, only cleaning up.

}
}

Expand Down
Loading
Loading