mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
Merge pull request #33 from arnauda-gh/fix/attachment-direct-view
fix(android): open attachments directly with default viewer app
This commit is contained in:
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+36
-3
@@ -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';
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user