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