From 476a01a78f11d14d27f37d31f7c60ee1fdb85c65 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 16 Jan 2026 10:52:47 +0200 Subject: [PATCH] attempt to fix blob mangement --- src/state/drafts/storage.ts | 12 +++++- src/state/drafts/storage.web.ts | 37 ++++++++++++++++- src/state/gallery.ts | 70 ++++++++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 5 deletions(-) diff --git a/src/state/drafts/storage.ts b/src/state/drafts/storage.ts index 95864e4246..c7cb121366 100644 --- a/src/state/drafts/storage.ts +++ b/src/state/drafts/storage.ts @@ -50,13 +50,21 @@ export async function saveMediaToLocal( const destPath = getMediaPath(localRefPath) + // Ensure source path has file:// prefix for expo-file-system + let normalizedSource = sourcePath + if (!sourcePath.startsWith('file://') && sourcePath.startsWith('/')) { + normalizedSource = `file://${sourcePath}` + } + try { - await copyAsync({from: sourcePath, to: destPath}) + await copyAsync({from: normalizedSource, to: destPath}) + // Update cache after successful save + mediaExistsCache.set(localRefPath, true) } catch (error) { logger.error('Failed to save media to drafts storage', { error, localRefPath, - sourcePath, + sourcePath: normalizedSource, destPath, }) throw error diff --git a/src/state/drafts/storage.web.ts b/src/state/drafts/storage.web.ts index 64c0befd6d..d0930878ac 100644 --- a/src/state/drafts/storage.web.ts +++ b/src/state/drafts/storage.web.ts @@ -38,7 +38,31 @@ async function getDB(): Promise> { * Convert a path/URL to a Blob */ async function toBlob(sourcePath: string): Promise { + // Handle data URIs directly + if (sourcePath.startsWith('data:')) { + const response = await fetch(sourcePath) + return response.blob() + } + + // Handle blob URLs + if (sourcePath.startsWith('blob:')) { + try { + const response = await fetch(sourcePath) + return response.blob() + } catch (e) { + logger.error('Failed to fetch blob URL - it may have been revoked', { + error: e, + sourcePath, + }) + throw e + } + } + + // Handle regular URLs const response = await fetch(sourcePath) + if (!response.ok) { + throw new Error(`Failed to fetch media: ${response.status}`) + } return response.blob() } @@ -50,7 +74,18 @@ export async function saveMediaToLocal( sourcePath: string, ): Promise { const db = await getDB() - const blob = await toBlob(sourcePath) + + let blob: Blob + try { + blob = await toBlob(sourcePath) + } catch (error) { + logger.error('Failed to convert source to blob', { + error, + localRefPath, + sourcePath, + }) + throw error + } try { await db.put( diff --git a/src/state/gallery.ts b/src/state/gallery.ts index 5b5eedd8b8..ea3445dba4 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -1,5 +1,6 @@ import { cacheDirectory, + copyAsync, deleteAsync, makeDirectoryAsync, moveAsync, @@ -18,7 +19,7 @@ import {openCropper} from '#/lib/media/picker' import {type PickerImage} from '#/lib/media/picker.shared' import {getDataUriSize} from '#/lib/media/util' import {isCancelledError} from '#/lib/strings/errors' -import {IS_NATIVE} from '#/env' +import {IS_NATIVE, IS_WEB} from '#/env' export type ImageTransformation = { crop?: ActionCrop['crop'] @@ -69,7 +70,8 @@ export async function createComposerImage( alt: '', source: { id: nanoid(), - path: await moveIfNecessary(raw.path), + // Copy to cache to ensure file survives OS temporary file cleanup + path: await copyToCache(raw.path), width: raw.width, height: raw.height, mime: raw.mime, @@ -258,6 +260,70 @@ async function moveIfNecessary(from: string) { return from } +/** + * Copy a file from a potentially temporary location to our cache directory. + * This ensures picker files are available for draft saving even if the original + * temporary files are cleaned up by the OS. + * + * On web, converts blob URLs to data URIs immediately to prevent revocation issues. + */ +async function copyToCache(from: string): Promise { + // Handle web blob URLs - convert to data URI immediately before they can be revoked + if (IS_WEB && from.startsWith('blob:')) { + try { + const response = await fetch(from) + const blob = await response.blob() + return await blobToDataUri(blob) + } catch (e) { + // If fetch fails, the blob URL was likely already revoked + // Return as-is and let downstream code handle the error + return from + } + } + + // Data URIs don't need any conversion + if (from.startsWith('data:')) { + return from + } + + const cacheDir = IS_WEB && getImageCacheDirectory() + + // On web (non-blob URLs) or if already in cache dir, no need to copy + if (!cacheDir || from.startsWith(cacheDir)) { + return from + } + + const to = joinPath(cacheDir, nanoid(36)) + await makeDirectoryAsync(cacheDir, {intermediates: true}) + + // Normalize the source path for expo-file-system + let normalizedFrom = from + if (!from.startsWith('file://') && from.startsWith('/')) { + normalizedFrom = `file://${from}` + } + + await copyAsync({from: normalizedFrom, to}) + return to +} + +/** + * Convert a Blob to a data URI + */ +function blobToDataUri(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onloadend = () => { + if (typeof reader.result === 'string') { + resolve(reader.result) + } else { + reject(new Error('Failed to convert blob to data URI')) + } + } + reader.onerror = () => reject(reader.error) + reader.readAsDataURL(blob) + }) +} + /** Purge files that were created to accomodate image manipulation */ export async function purgeTemporaryImageFiles() { const cacheDir = IS_NATIVE && getImageCacheDirectory()