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:
Linus Rath
2026-09-24 18:43:43 +02:00
parent 8d7fbfc99c
commit cd4ce9d08b
7 changed files with 211 additions and 17 deletions
+39
View File
@@ -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
View File
@@ -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,