migrate file system api
This commit is contained in:
@@ -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
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -193,6 +193,6 @@ function downloadUrl(href: string, filename: string) {
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
export async function safeDeleteAsync() {
|
||||
export function safeDelete() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Vendored
+6
@@ -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)[]
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user