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
+55 -48
View File
@@ -2,30 +2,20 @@
* Native file system storage for draft media. * Native file system storage for draft media.
* Media is stored by localRefPath key (unique identifier stored in server draft). * Media is stored by localRefPath key (unique identifier stored in server draft).
*/ */
import { import {Directory, File, Paths} from 'expo-file-system'
copyAsync,
deleteAsync,
documentDirectory,
getInfoAsync,
makeDirectoryAsync,
} from 'expo-file-system/legacy'
import {logger} from '#/logger' import {logger} from './logger'
const MEDIA_DIR = 'bsky-draft-media' const MEDIA_DIR = 'bsky-draft-media'
function joinPath(...segments: string[]): string { function getMediaDirectory(): Directory {
return segments.join('/').replace(/\/+/g, '/') return new Directory(Paths.document, MEDIA_DIR)
} }
function getMediaDirectory(): string { function getMediaFile(localRefPath: string): File {
return joinPath(documentDirectory!, MEDIA_DIR)
}
function getMediaPath(localRefPath: string): string {
// Use localRefPath as filename (replace unsafe chars) // Use localRefPath as filename (replace unsafe chars)
const safeFilename = localRefPath.replace(/[/:]/g, '_') const safeFilename = localRefPath.replace(/[/:]/g, '_')
return joinPath(getMediaDirectory(), safeFilename) return new File(getMediaDirectory(), safeFilename)
} }
let dirCreated = false let dirCreated = false
@@ -33,22 +23,25 @@ let dirCreated = false
/** /**
* Ensure the media directory exists * Ensure the media directory exists
*/ */
async function ensureDirectory(): Promise<void> { function ensureDirectory(): void {
if (dirCreated) return if (dirCreated) return
await makeDirectoryAsync(getMediaDirectory(), {intermediates: true}) const dir = getMediaDirectory()
if (!dir.exists) {
dir.create()
}
dirCreated = true dirCreated = true
} }
/** /**
* Save a media file to local storage by localRefPath key * Save a media file to local storage by localRefPath key
*/ */
export async function saveMediaToLocal( export function saveMediaToLocal(
localRefPath: string, localRefPath: string,
sourcePath: string, sourcePath: string,
): Promise<void> { ): void {
await ensureDirectory() ensureDirectory()
const destPath = getMediaPath(localRefPath) const destFile = getMediaFile(localRefPath)
// Ensure source path has file:// prefix for expo-file-system // Ensure source path has file:// prefix for expo-file-system
let normalizedSource = sourcePath let normalizedSource = sourcePath
@@ -57,7 +50,8 @@ export async function saveMediaToLocal(
} }
try { try {
await copyAsync({from: normalizedSource, to: destPath}) const sourceFile = new File(normalizedSource)
sourceFile.copy(destFile)
// Update cache after successful save // Update cache after successful save
mediaExistsCache.set(localRefPath, true) mediaExistsCache.set(localRefPath, true)
} catch (error) { } catch (error) {
@@ -65,7 +59,7 @@ export async function saveMediaToLocal(
error, error,
localRefPath, localRefPath,
sourcePath: normalizedSource, sourcePath: normalizedSource,
destPath, destPath: destFile.uri,
}) })
throw error throw error
} }
@@ -73,29 +67,27 @@ export async function saveMediaToLocal(
/** /**
* Load a media file path from local storage * 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( export function loadMediaFromLocal(localRefPath: string): string {
localRefPath: string, const file = getMediaFile(localRefPath)
): Promise<string> {
const path = getMediaPath(localRefPath)
const info = await getInfoAsync(path)
if (!info.exists) { if (!file.exists) {
throw new Error(`Media file not found: ${localRefPath}`) throw new Error(`Media file not found: ${localRefPath}`)
} }
return path return file.uri
} }
/** /**
* Delete a media file from local storage * Delete a media file from local storage
*/ */
export async function deleteMediaFromLocal( export function deleteMediaFromLocal(localRefPath: string): void {
localRefPath: string, const file = getMediaFile(localRefPath)
): Promise<void> { // Idempotent: only delete if file exists
const path = getMediaPath(localRefPath) if (file.exists) {
await deleteAsync(path, {idempotent: true}) file.delete()
}
} }
/** /**
@@ -107,27 +99,28 @@ let cachePopulated = false
export function mediaExists(localRefPath: string): boolean { export function mediaExists(localRefPath: string): boolean {
// For native, we need an async check but the API requires sync // 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)) { if (mediaExistsCache.has(localRefPath)) {
return mediaExistsCache.get(localRefPath)! return mediaExistsCache.get(localRefPath)!
} }
// If cache not populated yet, trigger async population and return true optimistically // If cache not populated yet, trigger async population
if (!cachePopulated) { if (!cachePopulated && !populateCachePromise) {
populateCache() populateCachePromise = populateCacheInternal()
} }
return false // Conservative: assume doesn't exist if not in cache return false // Conservative: assume doesn't exist if not in cache
} }
async function populateCache(): Promise<void> { let populateCachePromise: Promise<void> | null = null
function populateCacheInternal(): Promise<void> {
return new Promise(resolve => {
try { try {
const {readDirectoryAsync} = await import('expo-file-system/legacy')
const dir = getMediaDirectory() const dir = getMediaDirectory()
const info = await getInfoAsync(dir) if (dir.exists) {
if (info.exists) { const items = dir.list()
const files = await readDirectoryAsync(dir) for (const item of items) {
for (const file of files) {
// Reverse the safe filename transformation // Reverse the safe filename transformation
const localRefPath = file.replace(/_/g, ':').replace(/_/g, '/') const localRefPath = item.name.replace(/_/g, ':').replace(/_/g, '/')
mediaExistsCache.set(localRefPath, true) mediaExistsCache.set(localRefPath, true)
} }
} }
@@ -135,6 +128,19 @@ async function populateCache(): Promise<void> {
} catch (e) { } catch (e) {
logger.warn('Failed to populate media cache', {error: 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 { export function clearMediaCache(): void {
mediaExistsCache.clear() mediaExistsCache.clear()
cachePopulated = false cachePopulated = false
populateCachePromise = null
} }