Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions src/lib/__tests__/email-export.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

// Platform.OS is flipped per test, so hoist a mutable object into the module
// mock rather than using the fixed 'android' stub from test-setup.
const { platform } = vi.hoisted(() => ({ platform: { OS: 'android' as string, Version: 33 } }));

vi.mock('react-native', () => ({
Platform: platform,
NativeModules: {},
NativeEventEmitter: class {
addListener() {
return { remove: () => undefined };
}
},
}));

// A `File` that records both URL forms, so tests can assert which one each
// handoff path was given: expo-sharing needs `file://`, an Intent needs
// `content://`.
vi.mock('expo-file-system', () => {
class File {
name: string;
uri: string;
contentUri: string;
exists = true;
constructor(dir: { uri: string }, name: string) {
this.name = name;
this.uri = `${dir.uri}${name}`;
this.contentUri = `content://org.bulwarkmail.mobile.FileSystemFileProvider/${name}`;
}
create() {}
delete() {}
write() {}
static downloadFileAsync = vi.fn(async () => undefined);
}
return {
File,
Directory: class {},
Paths: { cache: { uri: 'file:///cache/' }, document: { uri: 'file:///documents/' } },
};
});

vi.mock('expo-intent-launcher', () => ({
startActivityAsync: vi.fn(async () => ({ resultCode: -1 })),
}));

vi.mock('expo-sharing', () => ({
isAvailableAsync: vi.fn(async () => true),
shareAsync: vi.fn(async () => undefined),
}));

vi.mock('../client-cert', () => ({
getClientCertAlias: vi.fn(async () => null),
secureFetch: vi.fn(),
}));

vi.mock('../../api/jmap-client', () => ({
jmapClient: { authHeader: 'Bearer token' },
}));

vi.mock('../../api/blob', () => ({
getDownloadUrl: vi.fn((blobId: string, name: string) => `https://mail.example.com/${blobId}/${name}`),
}));

import * as IntentLauncher from 'expo-intent-launcher';
import * as Sharing from 'expo-sharing';
import { shareAttachment, downloadAttachment } from '../email-export';

const VIEW = 'android.intent.action.VIEW';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;

describe('shareAttachment (preview)', () => {
beforeEach(() => {
platform.OS = 'android';
vi.mocked(IntentLauncher.startActivityAsync).mockClear().mockResolvedValue({ resultCode: -1 });
vi.mocked(Sharing.shareAsync).mockClear();
});

it('hands the file to a viewer app via a VIEW intent on Android', async () => {
await shareAttachment('blob-1', 'report.pdf', 'application/pdf');

expect(IntentLauncher.startActivityAsync).toHaveBeenCalledWith(VIEW, {
data: 'content://org.bulwarkmail.mobile.FileSystemFileProvider/report.pdf',
type: 'application/pdf',
flags: FLAG_GRANT_READ_URI_PERMISSION,
});
// The share sheet is what the direct handoff exists to avoid.
expect(Sharing.shareAsync).not.toHaveBeenCalled();
});

it('falls back to the share sheet when no app handles the type', async () => {
vi.mocked(IntentLauncher.startActivityAsync).mockRejectedValue(
new Error('No Activity found to handle Intent'),
);

await shareAttachment('blob-2', 'weird.xyz', 'application/x-weird');

// file:// — expo-sharing rejects content:// URLs outright.
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///cache/weird.xyz', {
mimeType: 'application/x-weird',
dialogTitle: 'weird.xyz',
});
});

it('surfaces a download failure instead of silently falling back', async () => {
const { File } = await import('expo-file-system');
vi.mocked(File.downloadFileAsync).mockRejectedValueOnce(new Error('Download failed: 404'));

await expect(shareAttachment('blob-3', 'gone.pdf', 'application/pdf')).rejects.toThrow(
'Download failed: 404',
);
expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
});

it('uses the share sheet on iOS', async () => {
platform.OS = 'ios';

await shareAttachment('blob-4', 'photo.jpg', 'image/jpeg');

expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///cache/photo.jpg', {
mimeType: 'image/jpeg',
dialogTitle: 'photo.jpg',
});
});
});

describe('downloadAttachment (save a copy)', () => {
beforeEach(() => {
platform.OS = 'android';
vi.mocked(IntentLauncher.startActivityAsync).mockClear().mockResolvedValue({ resultCode: -1 });
vi.mocked(Sharing.shareAsync).mockClear();
});

it('keeps the share sheet on Android so the save dialog stays reachable', async () => {
await downloadAttachment('blob-5', 'report.pdf', 'application/pdf');

expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///documents/report.pdf', {
mimeType: 'application/pdf',
dialogTitle: 'report.pdf',
});
});
});
39 changes: 36 additions & 3 deletions src/lib/email-export.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Platform } from 'react-native';
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';
Expand All @@ -12,6 +14,7 @@ import {
import { getClientCertAlias, secureFetch } from './client-cert';

const RFC822 = 'message/rfc822';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;

// Read the user's filename template + transform preferences for exports.
function emailFileOptions(): EmailFilenameOptions {
Expand Down Expand Up @@ -48,7 +51,32 @@ function safeAttachmentName(name: string | undefined, type: string | undefined):
// expo-sharing only accepts `file://` URLs and rejects `content://` with
// "Only local file URLs are supported". On Android it then wraps the file
// itself with its bundled SharingFileProvider before launching the share
// intent, so we must NOT pre-translate to `file.contentUri`.
// intent, so every `Sharing.shareAsync` below gets `downloaded.uri` and must
// NOT be pre-translated to `downloaded.contentUri`. `openWithViewer` is the
// one exception: it assembles the intent itself, so there it's the reverse —
// only a content URI is grantable to another app.

// Android-only: hand the file to whichever app owns the type (PDF viewer,
// gallery, video player, ...) rather than to the share sheet. Returns false
// when the handoff didn't happen so the caller can fall back to sharing —
// most often because no installed app handles the MIME type, in which case
// startActivityAsync rejects with ActivityNotFoundException.
async function openWithViewer(file: File, mimeType: string): Promise<boolean> {
try {
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
// Read-only grant, scoped to the receiving app for the life of the
// intent. Deliberately no FLAG_ACTIVITY_NEW_TASK: expo-intent-launcher
// uses startActivityForResult, and a new task cancels that result.
data: file.contentUri,
type: mimeType,
flags: FLAG_GRANT_READ_URI_PERMISSION,
});
return true;
} catch (e) {
console.warn('[attachments] no viewer for', mimeType, '- falling back to share:', e);
return false;
}
}

// Routes the download via the client-cert-aware native module when the user
// has picked a cert, and via expo-file-system's native streaming downloader
Expand Down Expand Up @@ -99,6 +127,10 @@ export async function shareAttachment(
const dest = new File(Paths.cache, filename);
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
const downloaded = await downloadInto(url, dest, Paths.cache);

if (Platform.OS === 'android' && (await openWithViewer(downloaded, mimeType))) {
return;
}
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
Expand All @@ -111,8 +143,9 @@ export async function shareAttachment(
// Save-to-disk variant. iOS doesn't expose a user-visible "Downloads" folder,
// so on both platforms we land the file in the document directory and hand it
// to the share sheet — which on iOS surfaces "Save to Files" and on Android
// surfaces the system save dialog. The 'preview' counterpart (shareAttachment)
// uses cache + share, which on most viewers opens directly without prompting.
// surfaces the system save dialog. Unlike the 'preview' counterpart
// (shareAttachment), this one keeps the share sheet on Android as well: "save
// a copy" is a share-sheet destination, not something a viewer app handles.
export async function downloadAttachment(
blobId: string,
name: string | undefined,
Expand Down