Migrate storage.ts from expo-file-system/legacy to expo-file-system

Use the new object-based expo-file-system API (SDK 54+) with Directory
and File classes instead of the legacy function-based API. The new API
provides synchronous operations for file/directory existence checks,
creation, copying, deletion, and listing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-01-16 11:27:42 +02:00
parent 476a01a78f
commit 9c8533c05e
+62 -55
View File
@@ -2,30 +2,20 @@
* Native file system storage for draft media.
* Media is stored by localRefPath key (unique identifier stored in server draft).
*/
import {
copyAsync,
deleteAsync,
documentDirectory,
getInfoAsync,
makeDirectoryAsync,
} from 'expo-file-system/legacy'
import {Directory, File, Paths} from 'expo-file-system'
import {logger} from '#/logger'
import {logger} from './logger'
const MEDIA_DIR = 'bsky-draft-media'
function joinPath(...segments: string[]): string {
return segments.join('/').replace(/\/+/g, '/')
function getMediaDirectory(): Directory {
return new Directory(Paths.document, MEDIA_DIR)
}
function getMediaDirectory(): string {
return joinPath(documentDirectory!, MEDIA_DIR)
}
function getMediaPath(localRefPath: string): string {
function getMediaFile(localRefPath: string): File {
// Use localRefPath as filename (replace unsafe chars)
const safeFilename = localRefPath.replace(/[/:]/g, '_')
return joinPath(getMediaDirectory(), safeFilename)
return new File(getMediaDirectory(), safeFilename)
}
let dirCreated = false
@@ -33,22 +23,25 @@ let dirCreated = false
/**
* Ensure the media directory exists
*/
async function ensureDirectory(): Promise<void> {
function ensureDirectory(): void {
if (dirCreated) return
await makeDirectoryAsync(getMediaDirectory(), {intermediates: true})
const dir = getMediaDirectory()
if (!dir.exists) {
dir.create()
}
dirCreated = true
}
/**
* Save a media file to local storage by localRefPath key
*/
export async function saveMediaToLocal(
export function saveMediaToLocal(
localRefPath: string,
sourcePath: string,
): Promise<void> {
await ensureDirectory()
): void {
ensureDirectory()
const destPath = getMediaPath(localRefPath)
const destFile = getMediaFile(localRefPath)
// Ensure source path has file:// prefix for expo-file-system
let normalizedSource = sourcePath
@@ -57,7 +50,8 @@ export async function saveMediaToLocal(
}
try {
await copyAsync({from: normalizedSource, to: destPath})
const sourceFile = new File(normalizedSource)
sourceFile.copy(destFile)
// Update cache after successful save
mediaExistsCache.set(localRefPath, true)
} catch (error) {
@@ -65,7 +59,7 @@ export async function saveMediaToLocal(
error,
localRefPath,
sourcePath: normalizedSource,
destPath,
destPath: destFile.uri,
})
throw error
}
@@ -73,29 +67,27 @@ export async function saveMediaToLocal(
/**
* Load a media file path from local storage
* @returns The file path for the saved media
* @returns The file URI for the saved media
*/
export async function loadMediaFromLocal(
localRefPath: string,
): Promise<string> {
const path = getMediaPath(localRefPath)
const info = await getInfoAsync(path)
export function loadMediaFromLocal(localRefPath: string): string {
const file = getMediaFile(localRefPath)
if (!info.exists) {
if (!file.exists) {
throw new Error(`Media file not found: ${localRefPath}`)
}
return path
return file.uri
}
/**
* Delete a media file from local storage
*/
export async function deleteMediaFromLocal(
localRefPath: string,
): Promise<void> {
const path = getMediaPath(localRefPath)
await deleteAsync(path, {idempotent: true})
export function deleteMediaFromLocal(localRefPath: string): void {
const file = getMediaFile(localRefPath)
// Idempotent: only delete if file exists
if (file.exists) {
file.delete()
}
}
/**
@@ -107,34 +99,48 @@ let cachePopulated = false
export function mediaExists(localRefPath: string): boolean {
// For native, we need an async check but the API requires sync
// Use cached result if available, otherwise assume exists (will fail on load if not)
// Use cached result if available, otherwise assume doesn't exist
if (mediaExistsCache.has(localRefPath)) {
return mediaExistsCache.get(localRefPath)!
}
// If cache not populated yet, trigger async population and return true optimistically
if (!cachePopulated) {
populateCache()
// If cache not populated yet, trigger async population
if (!cachePopulated && !populateCachePromise) {
populateCachePromise = populateCacheInternal()
}
return false // Conservative: assume doesn't exist if not in cache
}
async function populateCache(): Promise<void> {
try {
const {readDirectoryAsync} = await import('expo-file-system/legacy')
const dir = getMediaDirectory()
const info = await getInfoAsync(dir)
if (info.exists) {
const files = await readDirectoryAsync(dir)
for (const file of files) {
// Reverse the safe filename transformation
const localRefPath = file.replace(/_/g, ':').replace(/_/g, '/')
mediaExistsCache.set(localRefPath, true)
let populateCachePromise: Promise<void> | null = null
function populateCacheInternal(): Promise<void> {
return new Promise(resolve => {
try {
const dir = getMediaDirectory()
if (dir.exists) {
const items = dir.list()
for (const item of items) {
// Reverse the safe filename transformation
const localRefPath = item.name.replace(/_/g, ':').replace(/_/g, '/')
mediaExistsCache.set(localRefPath, true)
}
}
cachePopulated = true
} catch (e) {
logger.warn('Failed to populate media cache', {error: e})
}
cachePopulated = true
} catch (e) {
logger.warn('Failed to populate media cache', {error: e})
resolve()
})
}
/**
* Ensure the media cache is populated. Call this before checking mediaExists.
*/
export async function ensureMediaCachePopulated(): Promise<void> {
if (cachePopulated) return
if (!populateCachePromise) {
populateCachePromise = populateCacheInternal()
}
await populateCachePromise
}
/**
@@ -143,4 +149,5 @@ async function populateCache(): Promise<void> {
export function clearMediaCache(): void {
mediaExistsCache.clear()
cachePopulated = false
populateCachePromise = null
}