diff --git a/__tests__/lib/images.test.ts b/__tests__/lib/images.test.ts index 0ba18ecc0e..29c0f11f55 100644 --- a/__tests__/lib/images.test.ts +++ b/__tests__/lib/images.test.ts @@ -1,8 +1,4 @@ -import { - createDownloadResumable, - deleteAsync, - getInfoAsync, -} from 'expo-file-system/legacy' +import {File, Paths} from 'expo-file-system' import {ImageManipulator, SaveFormat} from 'expo-image-manipulator' import {IMAGE_SIZE_CONFIG_2K_1MB} from '../../src/lib/constants' @@ -23,6 +19,9 @@ describe('downloadAndResize', () => { const errorSpy = jest.spyOn(global.console, 'error') beforeEach(() => { + const mockedDownload = File.downloadFileAsync as jest.Mock + mockedDownload.mockResolvedValue(new File('file://downloaded-image.jpg')) + let savedImageCount = 0 const mockedManipulate = ImageManipulator.manipulate as jest.Mock mockedManipulate.mockImplementation(() => { @@ -51,14 +50,6 @@ describe('downloadAndResize', () => { }) it('should return resized image for valid URI and options', async () => { - const mockedFetch = createDownloadResumable as jest.Mock - mockedFetch.mockReturnValue({ - cancelAsync: jest.fn(), - downloadAsync: jest - .fn() - .mockResolvedValue({uri: 'file://resized-image.jpg'}), - }) - const opts: DownloadAndResizeOpts = { uri: 'https://example.com/image.jpg', maxDimension: 2000, @@ -71,11 +62,12 @@ describe('downloadAndResize', () => { ...mockResizedImage, path: 'file://resized-image-7.jpg', }) - expect(createDownloadResumable).toHaveBeenCalledWith( + expect(File.downloadFileAsync).toHaveBeenCalledWith( opts.uri, expect.anything(), { - cache: true, + idempotent: true, + signal: expect.any(AbortSignal), }, ) @@ -103,7 +95,7 @@ describe('downloadAndResize', () => { expect(resizedImage.saveAsync).toHaveBeenCalledWith( expect.objectContaining({format: SaveFormat.JPEG, compress: 1.0}), ) - const deletedPaths = (deleteAsync as jest.Mock).mock.calls.map( + const deletedPaths = (Paths.info as jest.Mock).mock.calls.map( ([path]) => path, ) expect(deletedPaths).toEqual( @@ -120,11 +112,8 @@ describe('downloadAndResize', () => { }) it('deletes a partial download when downloading fails', async () => { - const mockedFetch = createDownloadResumable as jest.Mock - mockedFetch.mockReturnValue({ - cancelAsync: jest.fn(), - downloadAsync: jest.fn().mockRejectedValue(new Error('download failed')), - }) + const mockedDownload = File.downloadFileAsync as jest.Mock + mockedDownload.mockRejectedValue(new Error('download failed')) const opts: DownloadAndResizeOpts = { uri: 'https://example.com/image.jpg', @@ -134,22 +123,19 @@ describe('downloadAndResize', () => { } await expect(downloadAndResize(opts)).rejects.toThrow('download failed') - expect(deleteAsync).toHaveBeenCalledWith(expect.stringMatching(/\.bin$/), { - idempotent: true, - }) + expect(Paths.info).toHaveBeenCalledWith(expect.stringMatching(/-download$/)) }) it('deletes every intermediate image when resizing fails', async () => { - const mockedFetch = createDownloadResumable as jest.Mock - mockedFetch.mockReturnValue({ - cancelAsync: jest.fn(), - downloadAsync: jest - .fn() - .mockResolvedValue({uri: 'file://downloaded-image.jpg'}), + const mockedManipulate = ImageManipulator.manipulate as jest.Mock + const createContext = mockedManipulate.getMockImplementation()! + mockedManipulate.mockImplementation((...args) => { + const context = createContext(...args) + if (mockedManipulate.mock.calls.length === 3) { + context.renderAsync.mockRejectedValue(new Error('render failed')) + } + return context }) - ;(getInfoAsync as jest.Mock) - .mockResolvedValueOnce({exists: true, size: 100}) - .mockRejectedValueOnce(new Error('stat failed')) const opts: DownloadAndResizeOpts = { uri: 'https://example.com/image.jpg', @@ -158,15 +144,14 @@ describe('downloadAndResize', () => { timeout: 10000, } - await expect(downloadAndResize(opts)).rejects.toThrow('stat failed') - const deletedPaths = (deleteAsync as jest.Mock).mock.calls.map( + await expect(downloadAndResize(opts)).rejects.toThrow('render failed') + const deletedPaths = (Paths.info as jest.Mock).mock.calls.map( ([path]) => path, ) expect(deletedPaths).toEqual( expect.arrayContaining([ 'file://resized-image-1.jpg', 'file://resized-image-2.jpg', - 'file://resized-image-3.jpg', ]), ) }) diff --git a/jest/jestSetup.js b/jest/jestSetup.js index 562c1063c7..1f4979d610 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -29,12 +29,57 @@ jest.mock('react-native-safe-area-context', () => { } }) -jest.mock('expo-file-system/legacy', () => ({ - getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}), - deleteAsync: jest.fn(), - moveAsync: jest.fn().mockResolvedValue(undefined), - createDownloadResumable: jest.fn(), -})) +jest.mock('expo-file-system', () => { + const join = parts => + parts + .map(part => (typeof part === 'string' ? part : part.uri)) + .join('/') + .replace(/([^:]\/)\/+/g, '$1') + + class File { + static downloadFileAsync = jest.fn() + + constructor(...parts) { + this.uri = join(parts) + this.exists = true + this.size = 100 + this.type = 'image/jpeg' + } + + copy = jest.fn() + delete = jest.fn() + move = jest.fn(async destination => { + this.uri = destination.uri + }) + write = jest.fn() + } + + class Directory { + static pickDirectoryAsync = jest.fn() + + constructor(...parts) { + this.uri = join(parts) + this.exists = true + } + + create = jest.fn() + createFile = jest.fn(name => new File(this, name)) + delete = jest.fn() + list = jest.fn(() => []) + } + + return { + Directory, + EncodingType: {Base64: 'base64'}, + File, + Paths: { + availableDiskSpace: 100, + cache: new Directory('file://cache'), + document: new Directory('file://document'), + info: jest.fn(() => ({exists: true, isDirectory: false})), + }, + } +}) jest.mock('expo-image-manipulator', () => { const createContext = () => { diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index e0435c5a4c..03c80e30f3 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -636,9 +636,6 @@ "src/lib/media/manip.ts": { "typescript/no-explicit-any": { "count": 1 - }, - "typescript/no-floating-promises": { - "count": 3 } }, "src/lib/media/manip.web.ts": { @@ -1212,11 +1209,6 @@ "count": 2 } }, - "src/state/shell/composer/index.tsx": { - "typescript/no-floating-promises": { - "count": 1 - } - }, "src/state/shell/onboarding.tsx": { "typescript/no-floating-promises": { "count": 5 @@ -1521,4 +1513,4 @@ "count": 33 } } -} \ No newline at end of file +} diff --git a/src/lib/api/upload-blob.ts b/src/lib/api/upload-blob.ts index c679e15d84..4921236d39 100644 --- a/src/lib/api/upload-blob.ts +++ b/src/lib/api/upload-blob.ts @@ -1,7 +1,8 @@ -import {copyAsync} from 'expo-file-system/legacy' +import uuid from 'react-native-uuid' +import {File, Paths} from 'expo-file-system' import {type BlobRef, type Client, type EncodingString} from '@atproto/lex' -import {safeDeleteAsync} from '#/lib/media/manip' +import {safeDelete} from '#/lib/media/manip' /** * The blob-upload response body: `{blob}`. lex `Client.uploadBlob` returns the @@ -89,9 +90,9 @@ async function withSafeFile( // Since we don't "own" the file, we should avoid renaming or modifying it. // Instead, let's copy it to a temporary file and use that (then remove the // temporary file). - const newPath = uri.replace(/\.jpe?g$/, '.bin') + const newPath = new File(Paths.cache, `${uuid.v4()}.bin`).uri try { - await copyAsync({from: uri, to: newPath}) + await new File(uri).copy(new File(newPath)) } catch { // Failed to copy the file, just use the original return await fn(uri) @@ -100,7 +101,7 @@ async function withSafeFile( return await fn(newPath) } finally { // Remove the temporary file - await safeDeleteAsync(newPath) + safeDelete(newPath) } } else { return fn(uri) diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 3977a861e3..ea4ec838cc 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -1,17 +1,6 @@ import {Image as RNImage} from 'react-native' import uuid from 'react-native-uuid' -import { - cacheDirectory, - copyAsync, - createDownloadResumable, - deleteAsync, - EncodingType, - getInfoAsync, - makeDirectoryAsync, - moveAsync, - StorageAccessFramework, - writeAsStringAsync, -} from 'expo-file-system/legacy' +import {Directory, EncodingType, File, Paths} from 'expo-file-system' import {SaveFormat} from 'expo-image-manipulator' import * as MediaLibrary from 'expo-media-library/legacy' import * as Sharing from 'expo-sharing' @@ -68,7 +57,7 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) { maxSize: opts.maxSize, }) } finally { - void safeDeleteAsync(path) + safeDelete(path) } } @@ -94,9 +83,9 @@ export async function shareImageModal({uri}: {uri: string}) { UTI: 'image/jpeg', }) } finally { - await safeDeleteAsync(downloadedPath) - if (jpegUri) await safeDeleteAsync(jpegUri) - if (imagePath) await safeDeleteAsync(imagePath) + safeDelete(downloadedPath) + if (jpegUri) safeDelete(jpegUri) + if (imagePath) safeDelete(imagePath) } } @@ -176,7 +165,7 @@ export async function saveImageToMediaLibrary({uri}: {uri: string}) { }) throw err } finally { - safeDeleteAsync(imagePath) + safeDelete(imagePath) } } @@ -239,19 +228,19 @@ async function doResize( intermediateUris.push(resizeRes.uri) - const fileInfo = await getInfoAsync(resizeRes.uri) - if (!fileInfo.exists) { + const file = new File(resizeRes.uri) + if (!file.exists) { throw new Error( 'The image manipulation library failed to create a new image.', ) } - if (fileInfo.size < opts.maxSize) { + if (file.size < opts.maxSize) { minQualityPercentage = qualityPercentage newDataUri = { path: normalizePath(resizeRes.uri), mime: 'image/jpeg', - size: fileInfo.size, + size: file.size, width: resizeRes.width, height: resizeRes.height, } @@ -271,12 +260,12 @@ async function doResize( newDataUri = undefined throw err } finally { - await safeDeleteAsync(imageRes.uri) - await Promise.all( - intermediateUris - .filter(uri => newDataUri?.path !== normalizePath(uri)) - .map(safeDeleteAsync), - ) + safeDelete(imageRes.uri) + for (const intermediateUri of intermediateUris) { + if (newDataUri?.path !== normalizePath(intermediateUri)) { + safeDelete(intermediateUri) + } + } } } @@ -288,44 +277,29 @@ async function moveToPermanentPath(path: string, ext: string): Promise { */ const filename = uuid.v4() - // cacheDirectory will not ever be null on native, but it could be on web. This function only ever gets called on - // native so we assert as a string. - const destinationPath = joinPath(cacheDirectory as string, filename + ext) - await copyAsync({ - from: normalizePath(path), - to: normalizePath(destinationPath), - }) - safeDeleteAsync(path) - return normalizePath(destinationPath) + const destination = new File(Paths.cache, filename + ext) + await new File(normalizePath(path)).copy(destination) + safeDelete(path) + return normalizePath(destination.uri) } -export async function safeDeleteAsync(path: string) { - // Normalize is necessary for Android, otherwise it doesn't delete. +export function safeDelete(path: string) { const normalizedPath = normalizePath(path) try { - await deleteAsync(normalizedPath, {idempotent: true}) + const info = Paths.info(normalizedPath) + if (info.isDirectory) { + new Directory(normalizedPath).delete() + } else if (info.exists) { + new File(normalizedPath).delete() + } } catch (e) { console.error('Failed to delete file', e) } } -function joinPath(a: string, b: string) { - if (a.endsWith('/')) { - if (b.startsWith('/')) { - return a.slice(0, -1) + b - } - return a + b - } else if (b.startsWith('/')) { - return a + b - } - return a + '/' + b -} - -function normalizePath(str: string, allPlatforms = false): string { - if (IS_ANDROID || allPlatforms) { - if (!str.startsWith('file://')) { - return `file://${str}` - } +function normalizePath(str: string): string { + if (str.startsWith('/')) { + return `file://${str}` } return str } @@ -356,20 +330,9 @@ export async function saveToDevice( }) return true } else { - const permissions = - await StorageAccessFramework.requestDirectoryPermissionsAsync() - - if (!permissions.granted) { - return false - } - - const fileUrl = await StorageAccessFramework.createFileAsync( - permissions.directoryUri, - filename, - type, - ) - - await writeAsStringAsync(fileUrl, encoded, { + const directory = await Directory.pickDirectoryAsync() + const file = directory.createFile(filename, type) + file.write(encoded, { encoding: EncodingType.Base64, }) return true @@ -385,65 +348,60 @@ async function withTempFile( encoded: string, cb: (url: string) => T | Promise, ): Promise { - // cacheDirectory will not ever be null so we assert as a string. // Using a directory so that the file name is not a random string - const tmpDirUri = joinPath(cacheDirectory as string, String(uuid.v4())) - await makeDirectoryAsync(tmpDirUri, {intermediates: true}) + const tmpDir = new Directory(Paths.cache, String(uuid.v4())) + tmpDir.create({intermediates: true}) try { - const tmpFileUrl = joinPath(tmpDirUri, filename) - await writeAsStringAsync(tmpFileUrl, encoded, { + const tmpFile = new File(tmpDir, filename) + tmpFile.write(encoded, { encoding: EncodingType.Base64, }) - return await cb(tmpFileUrl) + return await cb(tmpFile.uri) } finally { - safeDeleteAsync(tmpDirUri) + safeDelete(tmpDir.uri) } } async function downloadImage(uri: string, destName: string, timeout: number) { - // Download to a temp path first, then rename with the correct extension - // based on the response's mimeType. - const tempPath = `${cacheDirectory ?? ''}/${destName}.bin` - const dlResumable = createDownloadResumable(uri, tempPath, {cache: true}) + /* + * Download into a temporary directory so Expo can derive a filename from + * the response headers. We then use that file's MIME type to choose the + * permanent extension. + */ + const tempDir = new Directory(Paths.cache, `${destName}-download`) + tempDir.create({intermediates: true}) + + const controller = new AbortController() let timedOut = false - let downloadedPath: string | undefined - let finalPath: string | undefined - const to1 = setTimeout(() => { + const timeoutId = setTimeout(() => { timedOut = true - void dlResumable.cancelAsync().catch(() => undefined) + controller.abort() }, timeout) try { - let dlRes - try { - dlRes = await dlResumable.downloadAsync() - } finally { - clearTimeout(to1) - } - - if (!dlRes?.uri) { - if (timedOut) { - throw new Error('Failed to download image - timed out') - } else { - throw new Error('Failed to download image - dlRes is undefined') - } - } - - downloadedPath = dlRes.uri - const ext = extFromMime(dlRes.mimeType) - finalPath = `${cacheDirectory ?? ''}/${destName}.${ext}` - await moveAsync({from: downloadedPath, to: finalPath}) - - return normalizePath(finalPath) - } catch (err) { - await Promise.all( - [...new Set([tempPath, downloadedPath, finalPath])] - .filter(path => path !== undefined) - .map(safeDeleteAsync), + const downloaded = await File.downloadFileAsync(uri, tempDir, { + idempotent: true, + signal: controller.signal, + }) + const ext = extFromMime(downloaded.type) + const destination = new File( + Paths.cache, + ext ? `${destName}.${ext}` : `${destName}.bin`, ) + await downloaded.move(destination) + + return normalizePath(destination.uri) + } catch (err) { + if (timedOut) { + throw new Error('Failed to download image - timed out') + } + throw err + } finally { + clearTimeout(timeoutId) + safeDelete(tempDir.uri) } } @@ -454,6 +412,6 @@ const MIME_TO_EXT: Record = { 'image/gif': 'gif', } -function extFromMime(mimeType?: string | null): string { - return (mimeType && MIME_TO_EXT[mimeType]) || 'jpg' +function extFromMime(mimeType?: string | null): string | undefined { + return mimeType ? MIME_TO_EXT[mimeType] : undefined } diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts index 690dc32023..4a454419c1 100644 --- a/src/lib/media/manip.web.ts +++ b/src/lib/media/manip.web.ts @@ -193,6 +193,6 @@ function downloadUrl(href: string, filename: string) { document.body.removeChild(a) } -export async function safeDeleteAsync() { +export function safeDelete() { // no-op } diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx index 4fa17ecf37..65ee805fdf 100644 --- a/src/lib/media/picker.e2e.tsx +++ b/src/lib/media/picker.e2e.tsx @@ -1,9 +1,5 @@ import {Asset} from 'expo-asset' -import { - documentDirectory, - getInfoAsync, - readDirectoryAsync, -} from 'expo-file-system/legacy' +import {Directory, File, Paths} from 'expo-file-system' import {type ImagePickerResult} from 'expo-image-picker' import ExpoImageCropTool, { type OpenCropperOptions, @@ -19,27 +15,29 @@ async function getFile() { return await getAndroidFile() } - const imagesDir = documentDirectory! + const imagesDir = Paths.document.uri + .replace(/\/?$/, '/') .split('/') .slice(0, -6) .concat(['Media', 'DCIM', '100APPLE']) .join('/') - let files = await readDirectoryAsync(imagesDir) - files = files.filter(file => file.endsWith('.JPG')) - const file = `${imagesDir}/${files[0]}` + const file = new Directory(imagesDir) + .list() + .find( + (entry): entry is File => + entry instanceof File && entry.name.endsWith('.JPG'), + ) - const fileInfo = await getInfoAsync(file) - - if (!fileInfo.exists) { + if (!file?.exists) { throw new Error('Failed to get file info') } return await compressIfNeeded( { - path: file, + path: file.uri, mime: 'image/jpeg', - size: fileInfo.size, + size: file.size, width: 4288, height: 2848, }, @@ -61,10 +59,9 @@ async function getAndroidFile() { ) await asset.downloadAsync() - const path = asset.localUri! - const fileInfo = await getInfoAsync(path) + const file = new File(asset.localUri!) - if (!fileInfo.exists) { + if (!file.exists) { throw new Error('Failed to get file info') } @@ -75,9 +72,9 @@ async function getAndroidFile() { */ return await compressIfNeeded( { - path, + path: file.uri, mime: 'image/jpeg', - size: fileInfo.size, + size: file.size, width: 1432, height: 1025, }, diff --git a/src/lib/media/uriSize.ts b/src/lib/media/uriSize.ts index 7ac2898180..779651e699 100644 --- a/src/lib/media/uriSize.ts +++ b/src/lib/media/uriSize.ts @@ -1,9 +1,9 @@ -import {getInfoAsync} from 'expo-file-system/legacy' +import {File} from 'expo-file-system' -export async function getUriSize(uri: string): Promise { - const info = await getInfoAsync(uri) - if (!info.exists) { +export function getUriSize(uri: string): Promise { + const file = new File(uri) + if (!file.exists) { throw new Error('Failed to read image size') } - return info.size + return Promise.resolve(file.size) } diff --git a/src/platform/misc.web-check.d.ts b/src/platform/misc.web-check.d.ts index dfa41bf6a4..2e5d40784d 100644 --- a/src/platform/misc.web-check.d.ts +++ b/src/platform/misc.web-check.d.ts @@ -80,6 +80,11 @@ declare module 'expo-file-system' { slice(start?: number, end?: number, contentType?: string): Blob upload(url: string, options?: UploadOptions): Promise createUploadTask(url: string, options?: UploadOptions): UploadTask + static downloadFileAsync( + url: string, + destination: File | Directory, + options?: DownloadOptions, + ): Promise static createDownloadTask( url: string, destination: File | Directory, @@ -92,6 +97,7 @@ declare module 'expo-file-system' { } export class Directory extends ExpoFileSystemDirectory { + static pickDirectoryAsync(initialUri?: string): Promise constructor(...uris: (string | File | Directory)[]) get parentDirectory(): Directory list(): (Directory | File)[] diff --git a/src/screens/Settings/AboutSettings.tsx b/src/screens/Settings/AboutSettings.tsx index 0c227c7b44..286bbe852d 100644 --- a/src/screens/Settings/AboutSettings.tsx +++ b/src/screens/Settings/AboutSettings.tsx @@ -1,6 +1,6 @@ import {Platform} from 'react-native' import {setStringAsync} from 'expo-clipboard' -import * as FileSystem from 'expo-file-system/legacy' +import {Paths} from 'expo-file-system' import {Image} from 'expo-image' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' @@ -41,15 +41,13 @@ export function AboutSettingsScreen({}: Props) { const {mutate: onClearImageCache, isPending: isClearingImageCache} = useMutation({ mutationFn: async () => { - const freeSpaceBefore = await FileSystem.getFreeDiskStorageAsync() - await Promise.all([ - // expo-image's disk cache - Image.clearDiskCache(), - // full-resolution media-upload leftovers (picker/manipulator copies); - // the only in-app way for iOS users to reclaim this space - purgeTemporaryImageFiles(), - ]) - const freeSpaceAfter = await FileSystem.getFreeDiskStorageAsync() + const freeSpaceBefore = Paths.availableDiskSpace + // full-resolution media-upload leftovers (picker/manipulator copies); + // the only in-app way for iOS users to reclaim this space + purgeTemporaryImageFiles() + // expo-image's disk cache + await Image.clearDiskCache() + const freeSpaceAfter = Paths.availableDiskSpace const spaceDiff = freeSpaceBefore - freeSpaceAfter return spaceDiff * -1 }, diff --git a/src/state/gallery.ts b/src/state/gallery.ts index 15c3a9e5b6..d872d3516c 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -1,10 +1,4 @@ -import { - cacheDirectory, - copyAsync, - deleteAsync, - makeDirectoryAsync, - moveAsync, -} from 'expo-file-system/legacy' +import {Directory, File, Paths} from 'expo-file-system' import {type ImageManipulatorContext, SaveFormat} from 'expo-image-manipulator' import {nanoid} from 'nanoid/non-secure' @@ -50,11 +44,14 @@ type ComposerImageWithTransformation = ComposerImageBase & { export type ComposerImage = ComposerImageWithoutTransformation | ComposerImageWithTransformation -let _imageCacheDirectory: string +let _imageCacheDirectory: Directory -function getImageCacheDirectory(): string | null { +function getImageCacheDirectory(): Directory | null { if (IS_NATIVE) { - return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer')) + return (_imageCacheDirectory ??= new Directory( + Paths.cache, + 'bsky-composer', + )) } return null @@ -273,13 +270,14 @@ export async function compressImage( async function moveIfNecessary(from: string) { const cacheDir = IS_NATIVE && getImageCacheDirectory() - if (cacheDir && !from.startsWith(cacheDir)) { - const to = joinPath(cacheDir, nanoid(36)) + if (cacheDir && !from.startsWith(cacheDir.uri)) { + const source = new File(normalizeFileUri(from)) + const destination = new File(cacheDir, nanoid(36)) - await makeDirectoryAsync(cacheDir, {intermediates: true}) - await moveAsync({from, to}) + cacheDir.create({idempotent: true, intermediates: true}) + await source.move(destination) - return to + return destination.uri } return from @@ -316,20 +314,15 @@ async function copyToCache(from: string): Promise { // Native: copy to cache directory to survive OS temp file cleanup const cacheDir = getImageCacheDirectory() - if (!cacheDir || from.startsWith(cacheDir)) { + if (!cacheDir || from.startsWith(cacheDir.uri)) { return from } - const to = joinPath(cacheDir, nanoid(36)) - await makeDirectoryAsync(cacheDir, {intermediates: true}) + const destination = new File(cacheDir, nanoid(36)) + cacheDir.create({idempotent: true, intermediates: true}) - let normalizedFrom = from - if (!from.startsWith('file://') && from.startsWith('/')) { - normalizedFrom = `file://${from}` - } - - await copyAsync({from: normalizedFrom, to}) - return to + await new File(normalizeFileUri(from)).copy(destination) + return destination.uri } /** @@ -363,36 +356,42 @@ function blobToDataUri(blob: Blob): Promise { const SYSTEM_MEDIA_CACHE_DIRS = ['ImagePicker', 'ImageManipulator'] /** Purge files that were created to accomodate image manipulation */ -export async function purgeTemporaryImageFiles() { +export function purgeTemporaryImageFiles() { if (!IS_NATIVE) { return } const cacheDir = getImageCacheDirectory() if (cacheDir) { - await deleteAsync(cacheDir, {idempotent: true}) - await makeDirectoryAsync(cacheDir) + try { + if (cacheDir.exists) { + cacheDir.delete() + } + cacheDir.create() + } catch (err) { + logger.warn('Failed to purge composer image cache', {safeMessage: err}) + } } // We don't recreate these - the respective expo modules recreate them on // demand the next time they run. - await Promise.all( - SYSTEM_MEDIA_CACHE_DIRS.map(dir => - deleteAsync(joinPath(cacheDirectory!, dir), {idempotent: true}), - ), - ) + for (const dir of SYSTEM_MEDIA_CACHE_DIRS) { + try { + const directory = new Directory(Paths.cache, dir) + if (directory.exists) { + directory.delete() + } + } catch (err) { + logger.warn('Failed to purge system image cache', { + safeMessage: err, + directory: dir, + }) + } + } } -function joinPath(a: string, b: string) { - if (a.endsWith('/')) { - if (b.startsWith('/')) { - return a.slice(0, -1) + b - } - return a + b - } else if (b.startsWith('/')) { - return a + b - } - return a + '/' + b +function normalizeFileUri(uri: string): string { + return uri.startsWith('/') ? `file://${uri}` : uri } function containImageRes( diff --git a/tsconfig.check.web.json b/tsconfig.check.web.json index 4b2ae580f2..bcb6f217da 100644 --- a/tsconfig.check.web.json +++ b/tsconfig.check.web.json @@ -5,14 +5,6 @@ "paths": { "#/*": ["./src/*"], "crypto": ["./src/platform/crypto.ts"], - /* - * expo-file-system/legacy resolves to the package's raw TypeScript - * source, whose internals break under .web module suffixes. Point it - * at the compiled declarations, where skipLibCheck applies. - */ - "expo-file-system/legacy": [ - "./node_modules/expo-file-system/build/legacy/index.d.ts" - ], /* * Mirrors the root tsconfig mapping (paths does not merge across * extends): expo-file-system 57's `exports` map blocks the deep type