mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +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:
@@ -19,13 +19,13 @@ class MainActivity : ReactActivity() {
|
|||||||
setTheme(R.style.AppTheme);
|
setTheme(R.style.AppTheme);
|
||||||
super.onCreate(null)
|
super.onCreate(null)
|
||||||
NotificationTapStore.captureFromIntent(intent)
|
NotificationTapStore.captureFromIntent(intent)
|
||||||
ShareIntentStore.captureFromIntent(intent)
|
ShareIntentStore.captureFromIntent(intent, contentResolver)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
ShareIntentStore.captureFromIntent(intent)?.let { share ->
|
ShareIntentStore.captureFromIntent(intent, contentResolver)?.let { share ->
|
||||||
BulwarkFcmModule.emit("app:share", share.toMap())
|
BulwarkFcmModule.emit("app:share", share.toMap())
|
||||||
}
|
}
|
||||||
val payload = NotificationTapStore.captureFromIntent(intent) ?: return
|
val payload = NotificationTapStore.captureFromIntent(intent) ?: return
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.anonymous.bulwarkmobile
|
package com.anonymous.bulwarkmobile
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
import com.facebook.react.bridge.Arguments
|
import com.facebook.react.bridge.Arguments
|
||||||
import com.facebook.react.bridge.WritableMap
|
import com.facebook.react.bridge.WritableMap
|
||||||
|
|
||||||
@@ -20,7 +23,7 @@ object ShareIntentStore {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
fun captureFromIntent(intent: Intent?): SharePayload? {
|
fun captureFromIntent(intent: Intent?, resolver: ContentResolver): SharePayload? {
|
||||||
val action = intent?.action ?: return null
|
val action = intent?.action ?: return null
|
||||||
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
|
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
|
||||||
|
|
||||||
@@ -35,11 +38,14 @@ object ShareIntentStore {
|
|||||||
val type = intent.type
|
val type = intent.type
|
||||||
|
|
||||||
if (text.isNullOrBlank() && uris.isEmpty()) return null
|
if (text.isNullOrBlank() && uris.isEmpty()) return null
|
||||||
|
val files = uris.mapIndexed { i, uri -> describe(resolver, uri, type, i) }
|
||||||
val payload = SharePayload(
|
val payload = SharePayload(
|
||||||
text = text,
|
text = text,
|
||||||
subject = subject,
|
subject = subject,
|
||||||
uris = uris.map { it.toString() },
|
uris = uris.map { it.toString() },
|
||||||
mimeTypes = uris.map { type ?: "" },
|
mimeTypes = files.map { it.mimeType },
|
||||||
|
names = files.map { it.name },
|
||||||
|
sizes = files.map { it.size },
|
||||||
)
|
)
|
||||||
pending = payload
|
pending = payload
|
||||||
// Clear so a later lifecycle event doesn't replay this share.
|
// Clear so a later lifecycle event doesn't replay this share.
|
||||||
@@ -50,6 +56,46 @@ object ShareIntentStore {
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class SharedFile(val name: String, val mimeType: String, val size: Double)
|
||||||
|
|
||||||
|
// Ask the provider for the file's real name, type and size: a MediaStore
|
||||||
|
// URI ends in a bare row id ("1000000027") and SEND_MULTIPLE only carries
|
||||||
|
// a wildcard type such as "image/*". Size is -1 when unknown.
|
||||||
|
private fun describe(resolver: ContentResolver, uri: Uri, intentType: String?, index: Int): SharedFile {
|
||||||
|
var displayName: String? = null
|
||||||
|
var size = -1.0
|
||||||
|
try {
|
||||||
|
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val nameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (nameColumn >= 0 && !cursor.isNull(nameColumn)) displayName = cursor.getString(nameColumn)
|
||||||
|
val sizeColumn = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
if (sizeColumn >= 0 && !cursor.isNull(sizeColumn)) size = cursor.getLong(sizeColumn).toDouble()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Provider without OpenableColumns: fall back to the URI below.
|
||||||
|
}
|
||||||
|
val resolvedType = try {
|
||||||
|
resolver.getType(uri)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val mimeType = resolvedType ?: intentType?.takeUnless { it.contains('*') } ?: ""
|
||||||
|
val name = displayName?.takeIf { it.isNotBlank() } ?: fallbackName(uri, mimeType, index)
|
||||||
|
return SharedFile(name, mimeType, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last path segment plus an extension for the MIME type, so the
|
||||||
|
// recipient's mail client can still open the attachment.
|
||||||
|
private fun fallbackName(uri: Uri, mimeType: String, index: Int): String {
|
||||||
|
val base = uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
|
||||||
|
?: "shared-${index + 1}"
|
||||||
|
if (base.contains('.')) return base
|
||||||
|
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
|
||||||
|
return if (extension.isNullOrEmpty()) base else "$base.$extension"
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
private fun streamExtra(intent: Intent): Uri? =
|
private fun streamExtra(intent: Intent): Uri? =
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
@@ -71,12 +117,16 @@ object ShareIntentStore {
|
|||||||
val subject: String?,
|
val subject: String?,
|
||||||
val uris: List<String>,
|
val uris: List<String>,
|
||||||
val mimeTypes: List<String>,
|
val mimeTypes: List<String>,
|
||||||
|
val names: List<String>,
|
||||||
|
val sizes: List<Double>,
|
||||||
) {
|
) {
|
||||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||||
if (text != null) putString("text", text)
|
if (text != null) putString("text", text)
|
||||||
if (subject != null) putString("subject", subject)
|
if (subject != null) putString("subject", subject)
|
||||||
putArray("uris", Arguments.fromList(uris))
|
putArray("uris", Arguments.fromList(uris))
|
||||||
putArray("mimeTypes", Arguments.fromList(mimeTypes))
|
putArray("mimeTypes", Arguments.fromList(mimeTypes))
|
||||||
|
putArray("names", Arguments.fromList(names))
|
||||||
|
putArray("sizes", Arguments.fromList(sizes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,13 @@ const uploadTask = {
|
|||||||
cancelAsync: vi.fn(async () => undefined),
|
cancelAsync: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
const createUploadTask = vi.fn((..._args: unknown[]) => uploadTask);
|
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', () => ({
|
vi.mock('expo-file-system/legacy', () => ({
|
||||||
|
cacheDirectory: 'file:///cache/',
|
||||||
createUploadTask: (...args: unknown[]) => createUploadTask(...args),
|
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 },
|
FileSystemUploadType: { BINARY_CONTENT: 0, MULTIPART: 1 },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -125,4 +130,38 @@ describe('uploadBlob', () => {
|
|||||||
).rejects.toMatchObject({ name: 'AbortError' });
|
).rejects.toMatchObject({ name: 'AbortError' });
|
||||||
expect(createUploadTask).not.toHaveBeenCalled();
|
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 accountId = jmapClient.accountId;
|
||||||
const uploadUrl = uploadUrlFor(accountId);
|
const uploadUrl = uploadUrlFor(accountId);
|
||||||
const contentType = type || 'application/octet-stream';
|
const contentType = type || 'application/octet-stream';
|
||||||
const { onProgress, signal } = options;
|
const { signal } = options;
|
||||||
if (signal?.aborted) throw abortError();
|
if (signal?.aborted) throw abortError();
|
||||||
|
|
||||||
const alias = await getClientCertAlias();
|
const alias = await getClientCertAlias();
|
||||||
@@ -96,9 +96,49 @@ export async function uploadBlob(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LegacyFileSystem = await loadLegacyFileSystem();
|
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(
|
const task = LegacyFileSystem.createUploadTask(
|
||||||
uploadUrl,
|
uploadUrl,
|
||||||
uri,
|
fileUri,
|
||||||
{
|
{
|
||||||
httpMethod: 'POST',
|
httpMethod: 'POST',
|
||||||
uploadType: LegacyFileSystem.FileSystemUploadType.BINARY_CONTENT,
|
uploadType: LegacyFileSystem.FileSystemUploadType.BINARY_CONTENT,
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { shareAttachments } from '../share-intent';
|
||||||
|
|
||||||
|
describe('shareAttachments', () => {
|
||||||
|
it('uses the display name, type and size the providing app reported', () => {
|
||||||
|
expect(shareAttachments({
|
||||||
|
uris: ['content://media/external/images/media/1000000027'],
|
||||||
|
mimeTypes: ['image/png'],
|
||||||
|
names: ['Screenshot_20260924.png'],
|
||||||
|
sizes: [48213],
|
||||||
|
})).toEqual([{
|
||||||
|
uri: 'content://media/external/images/media/1000000027',
|
||||||
|
name: 'Screenshot_20260924.png',
|
||||||
|
type: 'image/png',
|
||||||
|
size: 48213,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the decoded last URI segment and leaves an unknown size out', () => {
|
||||||
|
const [a] = shareAttachments({
|
||||||
|
uris: ['content://com.example.files/share/Quarterly%20report.pdf'],
|
||||||
|
mimeTypes: ['application/pdf'],
|
||||||
|
names: [''],
|
||||||
|
sizes: [-1],
|
||||||
|
});
|
||||||
|
expect(a).toEqual({
|
||||||
|
uri: 'content://com.example.files/share/Quarterly%20report.pdf',
|
||||||
|
name: 'Quarterly report.pdf',
|
||||||
|
type: 'application/pdf',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a missing or wildcard MIME type as a generic binary', () => {
|
||||||
|
const list = shareAttachments({
|
||||||
|
uris: ['content://a/1', 'content://a/2'],
|
||||||
|
mimeTypes: ['image/*', ''],
|
||||||
|
});
|
||||||
|
expect(list.map((a) => a.type)).toEqual(['application/octet-stream', 'application/octet-stream']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps names, types and sizes aligned for multiple files', () => {
|
||||||
|
const list = shareAttachments({
|
||||||
|
uris: ['content://a/1', 'content://a/2'],
|
||||||
|
mimeTypes: ['image/jpeg', 'video/mp4'],
|
||||||
|
names: ['one.jpg', 'two.mp4'],
|
||||||
|
sizes: [10, 20],
|
||||||
|
});
|
||||||
|
expect(list.map((a) => [a.name, a.type, a.size])).toEqual([
|
||||||
|
['one.jpg', 'image/jpeg', 10],
|
||||||
|
['two.mp4', 'video/mp4', 20],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+19
-10
@@ -34,17 +34,26 @@ export function addShareListener(listener: (payload: SharePayload) => void): ()
|
|||||||
/** Attachment descriptors for the composer's `prefillAttachments`. */
|
/** Attachment descriptors for the composer's `prefillAttachments`. */
|
||||||
export function shareAttachments(
|
export function shareAttachments(
|
||||||
share: SharePayload,
|
share: SharePayload,
|
||||||
): Array<{ uri: string; name: string; type: string }> {
|
): Array<{ uri: string; name: string; type: string; size?: number }> {
|
||||||
const uris = share.uris ?? [];
|
const uris = share.uris ?? [];
|
||||||
return uris.map((uri, i) => {
|
return uris.map((uri, i) => {
|
||||||
const last = uri.split('/').filter(Boolean).pop() ?? `shared-${i + 1}`;
|
const mimeType = share.mimeTypes?.[i];
|
||||||
let name: string;
|
const type = mimeType && !mimeType.includes('*') ? mimeType : 'application/octet-stream';
|
||||||
try {
|
const size = share.sizes?.[i];
|
||||||
name = decodeURIComponent(last);
|
return {
|
||||||
} catch {
|
uri,
|
||||||
name = last;
|
name: share.names?.[i]?.trim() || nameFromUri(uri, i),
|
||||||
}
|
type,
|
||||||
const type = share.mimeTypes?.[i] || 'application/octet-stream';
|
...(typeof size === 'number' && size >= 0 ? { size } : {}),
|
||||||
return { uri, name, type };
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nameFromUri(uri: string, i: number): string {
|
||||||
|
const last = uri.split('/').filter(Boolean).pop() ?? `shared-${i + 1}`;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(last);
|
||||||
|
} catch {
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -181,9 +181,12 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr
|
|||||||
export interface SharePayload {
|
export interface SharePayload {
|
||||||
text?: string;
|
text?: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
// content:// URIs of shared files, with their MIME types when known.
|
// content:// URIs of shared files, with their MIME types, display names
|
||||||
|
// and sizes (-1 when unknown) as reported by the providing app.
|
||||||
uris?: string[];
|
uris?: string[];
|
||||||
mimeTypes?: string[];
|
mimeTypes?: string[];
|
||||||
|
names?: string[];
|
||||||
|
sizes?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user