mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 16:07:12 +00:00
fix: remove shared-file copies left behind by an upload cut short
Files shared into the app are copied into the cache as `upload-*` before they are uploaded. When the app was killed mid-upload the copy was never deleted. The launch-time sweep of stale export files now also removes upload copies made by an earlier run of the app; copies of the current run may still be uploading and are left alone.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
+10
-3
@@ -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<void> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user