diff --git a/src/api/__tests__/blob.test.ts b/src/api/__tests__/blob.test.ts index 4fc03585..7fa818a3 100644 --- a/src/api/__tests__/blob.test.ts +++ b/src/api/__tests__/blob.test.ts @@ -40,7 +40,7 @@ vi.mock('../jmap-client', () => ({ }, })); -import { getDownloadUrl, uploadBlob } from '../blob'; +import { getDownloadUrl, isStaleUploadCopy, uploadBlob } from '../blob'; beforeEach(() => { vi.clearAllMocks(); @@ -165,3 +165,23 @@ describe('uploadBlob', () => { expect(createUploadTask).not.toHaveBeenCalled(); }); }); + +describe('isStaleUploadCopy', () => { + it('flags upload copies an earlier run of the app left behind', () => { + expect(isStaleUploadCopy('upload-1700000000000-3')).toBe(true); + }); + + it("leaves this run's copies alone, since they may still be uploading", async () => { + uploadTask.uploadAsync.mockResolvedValueOnce({ status: 200, body: JSON.stringify({ blobId: 'b' }) }); + await uploadBlob('content://com.example.provider/doc/9', 'image/png'); + const name = copyAsync.mock.calls[0][0].to.replace('file:///cache/', ''); + + expect(isStaleUploadCopy(name)).toBe(false); + }); + + it('ignores other cache files', () => { + expect(isStaleUploadCopy('upload-notes.txt')).toBe(false); + expect(isStaleUploadCopy('bulwark-exports')).toBe(false); + expect(isStaleUploadCopy('x-upload-1700000000000-3')).toBe(false); + }); +}); diff --git a/src/api/blob.ts b/src/api/blob.ts index ba88c492..93b6d5a4 100644 --- a/src/api/blob.ts +++ b/src/api/blob.ts @@ -107,6 +107,18 @@ export async function uploadBlob( } let cacheCopySeq = 0; +const UPLOAD_COPY_NAME = /^upload-(\d+)-\d+$/; +const loadedAt = Date.now(); + +/** + * Whether a cache file is an upload copy (see below) made by an earlier run + * of the app: one killed mid-upload never deleted it. Copies of this run may + * still be uploading and are left alone. + */ +export function isStaleUploadCopy(name: string): boolean { + const match = UPLOAD_COPY_NAME.exec(name); + return !!match && Number(match[1]) < loadedAt; +} // The native upload task reads from a file path, but files shared into the // app from another app arrive as Android `content://` URIs with nothing on diff --git a/src/lib/__tests__/email-export.test.ts b/src/lib/__tests__/email-export.test.ts index 3244c6e5..47308cd4 100644 --- a/src/lib/__tests__/email-export.test.ts +++ b/src/lib/__tests__/email-export.test.ts @@ -65,7 +65,10 @@ vi.mock('expo-file-system', () => { return { File, Directory, - Paths: { cache: { uri: 'file:///cache/' }, document: { uri: 'file:///documents/' } }, + Paths: { + cache: { uri: 'file:///cache/', list: () => fsState.listings.get('file:///cache/') ?? [] }, + document: { uri: 'file:///documents/' }, + }, }; }); @@ -97,6 +100,7 @@ vi.mock('../../api/jmap-client', () => ({ vi.mock('../../api/blob', () => ({ getDownloadUrl: vi.fn((blobId: string, name: string) => `https://mail.example.com/${blobId}/${name}`), + isStaleUploadCopy: (name: string) => name.startsWith('upload-old-'), })); import * as IntentLauncher from 'expo-intent-launcher'; @@ -356,4 +360,19 @@ describe('sweepStaleExportFiles', () => { handedOff.uri, ]); }); + + it('removes the upload copies an earlier run left in the cache', async () => { + vi.resetModules(); + const fsMod = await import('expo-file-system'); + const { sweepStaleExportFiles } = await import('../email-export'); + fsState.deleted.length = 0; + fsState.listings.clear(); + const cache = { uri: 'file:///cache/' }; + fsState.listings.set(cache.uri, ['upload-old-1', 'upload-current-2', 'image-cache.png'] + .map((name) => new fsMod.File(cache as never, name))); + + await sweepStaleExportFiles(); + + expect(fsState.deleted).toEqual(['file:///cache/upload-old-1']); + }); }); diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index 0469192d..777defbb 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -3,7 +3,7 @@ import { Directory, File, Paths } from 'expo-file-system'; import * as IntentLauncher from 'expo-intent-launcher'; import * as Sharing from 'expo-sharing'; import { jmapClient } from '../api/jmap-client'; -import { getDownloadUrl } from '../api/blob'; +import { getDownloadUrl, isStaleUploadCopy } from '../api/blob'; import type { Attachment, Email } from '../api/types'; import { useSettingsStore } from '../stores/settings-store'; import { @@ -89,13 +89,20 @@ function deleteStaleFiles(dir: Directory, now: number, maxAgeMs: number): void { * Remove temp files older than a day. Files shared into other apps could not * always be deleted right after the share sheet closed (the receiving app may * still be reading them), so anything that slipped through is collected here, - * including preview folders whose file went to another app. Runs once per - * process, lazily before the first export, and is also safe to call at launch. + * including preview folders whose file went to another app. Also removes the + * cache copies of shared files that an earlier run was killed while uploading + * (`api/blob`). Runs once per process, lazily before the first export, and is + * also safe to call at launch. */ let sweptThisProcess = false; export async function sweepStaleExportFiles(maxAgeMs = STALE_EXPORT_MS): Promise { if (sweptThisProcess) return; sweptThisProcess = true; + try { + for (const entry of Paths.cache.list()) { + if (entry instanceof File && isStaleUploadCopy(entry.name)) deleteQuietly(entry); + } + } catch { /* housekeeping only */ } try { const dir = exportsDir(); if (!dir.exists) return;