mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: upload files shared into the app with their real name and type
Sharing a photo or document from another app into Bulwark failed every time: the upload task was handed the share's `content://` URI, but it can only stream from a file path, so it failed with "Directory for '/external/images/media/…' doesn't exist". The attachment was also named after the URI's last segment (a MediaStore row id such as "1000000027"), and SEND_MULTIPLE shares only carried a wildcard type like `image/*`. - `uploadBlob` copies `content://` URIs into the cache, uploads the copy and deletes it afterwards, also when the upload fails - `ShareIntentStore` asks the providing app for the display name, size and MIME type when the intent arrives, falling back to the URI segment plus an extension for the MIME type - the composer gets the real name, type and size, so the size limit check now applies to shared files too
This commit is contained in:
@@ -14,8 +14,13 @@ const uploadTask = {
|
||||
cancelAsync: vi.fn(async () => undefined),
|
||||
};
|
||||
const createUploadTask = vi.fn((..._args: unknown[]) => uploadTask);
|
||||
const copyAsync = vi.fn(async (_options: { from: string; to: string }) => undefined);
|
||||
const deleteAsync = vi.fn(async (_uri: string, _options?: unknown) => undefined);
|
||||
vi.mock('expo-file-system/legacy', () => ({
|
||||
cacheDirectory: 'file:///cache/',
|
||||
createUploadTask: (...args: unknown[]) => createUploadTask(...args),
|
||||
copyAsync: (options: { from: string; to: string }) => copyAsync(options),
|
||||
deleteAsync: (uri: string, options?: unknown) => deleteAsync(uri, options),
|
||||
FileSystemUploadType: { BINARY_CONTENT: 0, MULTIPART: 1 },
|
||||
}));
|
||||
|
||||
@@ -125,4 +130,38 @@ describe('uploadBlob', () => {
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(createUploadTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads files on disk directly without copying them', async () => {
|
||||
uploadTask.uploadAsync.mockResolvedValueOnce({ status: 200, body: JSON.stringify({ blobId: 'b' }) });
|
||||
await uploadBlob('file:///tmp/a.pdf', 'application/pdf');
|
||||
expect(copyAsync).not.toHaveBeenCalled();
|
||||
expect(deleteAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('copies a shared content:// URI into the cache, uploads the copy and deletes it', async () => {
|
||||
uploadTask.uploadAsync.mockResolvedValueOnce({ status: 200, body: JSON.stringify({ blobId: 'blob-3' }) });
|
||||
const shared = 'content://media/external/images/media/1000000027';
|
||||
|
||||
const result = await uploadBlob(shared, 'image/png');
|
||||
|
||||
expect(result.blobId).toBe('blob-3');
|
||||
const { from, to } = copyAsync.mock.calls[0][0];
|
||||
expect(from).toBe(shared);
|
||||
expect(to).toMatch(/^file:\/\/\/cache\/upload-/);
|
||||
expect(createUploadTask.mock.calls[0][1]).toBe(to);
|
||||
expect(deleteAsync).toHaveBeenCalledWith(to, { idempotent: true });
|
||||
});
|
||||
|
||||
it('deletes the cache copy when the upload fails', async () => {
|
||||
uploadTask.uploadAsync.mockResolvedValueOnce({ status: 500, body: 'boom' });
|
||||
await expect(uploadBlob('content://com.example.provider/doc/7', 'application/pdf')).rejects.toThrow(/500/);
|
||||
const { to } = copyAsync.mock.calls[0][0];
|
||||
expect(deleteAsync).toHaveBeenCalledWith(to, { idempotent: true });
|
||||
});
|
||||
|
||||
it('does not upload when the shared file cannot be copied', async () => {
|
||||
copyAsync.mockRejectedValueOnce(new Error('Permission Denial'));
|
||||
await expect(uploadBlob('content://com.example.provider/doc/8', 'image/jpeg')).rejects.toThrow(/Permission Denial/);
|
||||
expect(createUploadTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+42
-2
@@ -87,7 +87,7 @@ export async function uploadBlob(
|
||||
const accountId = jmapClient.accountId;
|
||||
const uploadUrl = uploadUrlFor(accountId);
|
||||
const contentType = type || 'application/octet-stream';
|
||||
const { onProgress, signal } = options;
|
||||
const { signal } = options;
|
||||
if (signal?.aborted) throw abortError();
|
||||
|
||||
const alias = await getClientCertAlias();
|
||||
@@ -96,9 +96,49 @@ export async function uploadBlob(
|
||||
}
|
||||
|
||||
const LegacyFileSystem = await loadLegacyFileSystem();
|
||||
const cacheCopy = await copyContentUriToCache(LegacyFileSystem, uri);
|
||||
try {
|
||||
return await uploadFileStreamed(LegacyFileSystem, cacheCopy ?? uri, contentType, accountId, uploadUrl, options);
|
||||
} finally {
|
||||
if (cacheCopy) {
|
||||
await LegacyFileSystem.deleteAsync(cacheCopy, { idempotent: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cacheCopySeq = 0;
|
||||
|
||||
// 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
|
||||
// disk behind them. Stream those into the cache first; returns null when
|
||||
// `uri` is already a file.
|
||||
async function copyContentUriToCache(
|
||||
LegacyFileSystem: LegacyFileSystemModule,
|
||||
uri: string,
|
||||
): Promise<string | null> {
|
||||
if (!uri.startsWith('content://')) return null;
|
||||
const to = `${LegacyFileSystem.cacheDirectory}upload-${Date.now()}-${++cacheCopySeq}`;
|
||||
try {
|
||||
await LegacyFileSystem.copyAsync({ from: uri, to });
|
||||
} catch (e) {
|
||||
await LegacyFileSystem.deleteAsync(to, { idempotent: true }).catch(() => undefined);
|
||||
throw e;
|
||||
}
|
||||
return to;
|
||||
}
|
||||
|
||||
async function uploadFileStreamed(
|
||||
LegacyFileSystem: LegacyFileSystemModule,
|
||||
fileUri: string,
|
||||
contentType: string,
|
||||
accountId: string,
|
||||
uploadUrl: string,
|
||||
{ onProgress, signal }: UploadBlobOptions,
|
||||
): Promise<UploadResult> {
|
||||
if (signal?.aborted) throw abortError();
|
||||
const task = LegacyFileSystem.createUploadTask(
|
||||
uploadUrl,
|
||||
uri,
|
||||
fileUri,
|
||||
{
|
||||
httpMethod: 'POST',
|
||||
uploadType: LegacyFileSystem.FileSystemUploadType.BINARY_CONTENT,
|
||||
|
||||
Reference in New Issue
Block a user