From b7995fafde7517b8628d37a65fa1472324e0fbcb Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:03:28 +0200 Subject: [PATCH 1/2] fix(android): open attachments directly with default viewer app Fixes #32. On Android, Sharing.shareAsync triggers Intent.ACTION_SEND (Share sheet). Updated shareAttachment to convert the file URI to a content URI and launch android.intent.action.VIEW via expo-intent-launcher, with fallback to shareAsync if no viewer application is installed for the MIME type. --- src/lib/email-export.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index 8f7e295..bdf179d 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -1,4 +1,7 @@ +import { Platform } from 'react-native'; import { Directory, File, Paths } from 'expo-file-system'; +import * as FileSystemLegacy from 'expo-file-system/legacy'; +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 +15,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 { @@ -99,6 +103,21 @@ 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') { + try { + const contentUri = await FileSystemLegacy.getContentUriAsync(downloaded.uri); + await IntentLauncher.startActivityAsync('android.intent.action.VIEW', { + data: contentUri, + type: mimeType, + flags: FLAG_GRANT_READ_URI_PERMISSION, + }); + return; + } catch { + // Fallback to sharing dialog if no viewer handles android.intent.action.VIEW + } + } + if (!(await Sharing.isAvailableAsync())) { throw new Error('Sharing is not available on this device'); } From ee21c15fec3e91cc544d424446f7c3193fe7415a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:36:31 +0200 Subject: [PATCH 2/2] refactor(android): tidy the attachment direct-view handoff Follow-up to the direct-view fix. No behaviour change on the happy path, but the handoff is now easier to reason about and covered by tests. - Use `File.contentUri` from the expo-file-system 19 API instead of the legacy `getContentUriAsync`, dropping the `expo-file-system/legacy` import. Same FileProvider, one less round trip through the bridge. - Pull the intent launch into `openWithViewer`, which returns false when the handoff didn't happen, so the fallback reads as a decision rather than as an empty catch. It also no longer swallows the reason silently. - Rewrite the expo-sharing comment above `shareAttachment`: it warns against pre-translating to a content URI, which is still true for every `Sharing.shareAsync` call but is exactly what the new VIEW path has to do. Note why `downloadAttachment` keeps the share sheet as well. - Record why FLAG_ACTIVITY_NEW_TASK is deliberately absent: the module launches via startActivityForResult, which a new task would cancel. --- src/lib/__tests__/email-export.test.ts | 144 +++++++++++++++++++++++++ src/lib/email-export.ts | 48 ++++++--- 2 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 src/lib/__tests__/email-export.test.ts diff --git a/src/lib/__tests__/email-export.test.ts b/src/lib/__tests__/email-export.test.ts new file mode 100644 index 0000000..5c92d24 --- /dev/null +++ b/src/lib/__tests__/email-export.test.ts @@ -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', + }); + }); +}); diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index bdf179d..890b22f 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -1,6 +1,5 @@ import { Platform } from 'react-native'; import { Directory, File, Paths } from 'expo-file-system'; -import * as FileSystemLegacy from 'expo-file-system/legacy'; import * as IntentLauncher from 'expo-intent-launcher'; import * as Sharing from 'expo-sharing'; import { jmapClient } from '../api/jmap-client'; @@ -52,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 { + 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 @@ -104,20 +128,9 @@ export async function shareAttachment( const url = getDownloadUrl(blobId, filename, mimeType, accountId); const downloaded = await downloadInto(url, dest, Paths.cache); - if (Platform.OS === 'android') { - try { - const contentUri = await FileSystemLegacy.getContentUriAsync(downloaded.uri); - await IntentLauncher.startActivityAsync('android.intent.action.VIEW', { - data: contentUri, - type: mimeType, - flags: FLAG_GRANT_READ_URI_PERMISSION, - }); - return; - } catch { - // Fallback to sharing dialog if no viewer handles android.intent.action.VIEW - } + if (Platform.OS === 'android' && (await openWithViewer(downloaded, mimeType))) { + return; } - if (!(await Sharing.isAvailableAsync())) { throw new Error('Sharing is not available on this device'); } @@ -130,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,