migrate file system api

This commit is contained in:
Samuel Newman
2026-09-04 13:54:39 +03:00
parent a197340bce
commit ece5693362
12 changed files with 228 additions and 255 deletions
+21 -36
View File
@@ -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',
]),
)
})
+51 -6
View File
@@ -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 = () => {
+1 -9
View File
@@ -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
}
}
}
}
+6 -5
View File
@@ -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<T>(
// 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<T>(
return await fn(newPath)
} finally {
// Remove the temporary file
await safeDeleteAsync(newPath)
safeDelete(newPath)
}
} else {
return fn(uri)
+71 -113
View File
@@ -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<string> {
*/
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<T>(
encoded: string,
cb: (url: string) => T | Promise<T>,
): Promise<T> {
// 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<string, string> = {
'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
}
+1 -1
View File
@@ -193,6 +193,6 @@ function downloadUrl(href: string, filename: string) {
document.body.removeChild(a)
}
export async function safeDeleteAsync() {
export function safeDelete() {
// no-op
}
+16 -19
View File
@@ -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,
},
+5 -5
View File
@@ -1,9 +1,9 @@
import {getInfoAsync} from 'expo-file-system/legacy'
import {File} from 'expo-file-system'
export async function getUriSize(uri: string): Promise<number> {
const info = await getInfoAsync(uri)
if (!info.exists) {
export function getUriSize(uri: string): Promise<number> {
const file = new File(uri)
if (!file.exists) {
throw new Error('Failed to read image size')
}
return info.size
return Promise.resolve(file.size)
}
+6
View File
@@ -80,6 +80,11 @@ declare module 'expo-file-system' {
slice(start?: number, end?: number, contentType?: string): Blob
upload(url: string, options?: UploadOptions): Promise<UploadResult>
createUploadTask(url: string, options?: UploadOptions): UploadTask
static downloadFileAsync(
url: string,
destination: File | Directory,
options?: DownloadOptions,
): Promise<File>
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<Directory>
constructor(...uris: (string | File | Directory)[])
get parentDirectory(): Directory
list(): (Directory | File)[]
+8 -10
View File
@@ -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
},
+42 -43
View File
@@ -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<string> {
// 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<string> {
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(
-8
View File
@@ -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