Files
native/src/lib/__tests__/email-export.test.ts
T
Linus Rath 409cdb139e feat: preview images, text and PDFs in the Files tab
Tapping a file in the Files tab downloaded it and went straight to the
Android "Open with" chooser or the iOS share sheet. It now opens the
reader's attachment preview for the types that preview can show, and
hands only the rest to another app, as before.

- `filePreviewKind` (`src/lib/file-preview.ts`) builds on the reader's
  `previewKindFor` and adds source code and config files as text; HTML,
  SVG, images React Native cannot decode and PDFs on Android (no in-app
  renderer) still go to another app
- files over a cap per type (text 512 KB, .eml 10 MB, images 25 MB,
  PDFs 50 MB) open in another app instead; when a node has no size,
  the downloaded file is checked
- the preview downloads the file once, with the same authenticated
  download as Download, into a temp folder of its own; Open with, Share
  and Download reuse that copy, so they work without another fetch
- the folder is deleted when the preview closes, or left to the
  stale-export sweep, which now also clears preview folders, once
  another app got the file
- the per-file menu offers Preview and "Open with…" instead of the
  combined "Preview / Share"
- the shared preview gains a header Open button, an optional Download
  button, labels on its icon buttons and a fallback when an image does
  not decode; the .eml parsing moves into `emlPreviewFromBytes`, which
  the reader uses too
2026-09-24 19:28:37 +02:00

360 lines
13 KiB
TypeScript

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 };
}
},
}));
// What the mocked file system was asked to do: deleted and copied paths, and
// directory listings (by directory uri) for the stale-file sweep.
const { fsState } = vi.hoisted(() => ({
fsState: {
deleted: [] as string[],
copies: [] as Array<[string, string]>,
listings: new Map<string, Array<{ uri: string }>>(),
},
}));
// 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;
modificationTime: number | null = null;
private parent: { uri: string };
constructor(dir: { uri: string }, name: string) {
this.parent = dir;
this.name = name;
this.uri = `${dir.uri}${name}`;
this.contentUri = `content://org.bulwarkmail.mobile.FileSystemFileProvider/${name}`;
}
get parentDirectory() { return this.parent; }
create() {}
delete() { fsState.deleted.push(this.uri); }
write() {}
copy(dest: { uri: string }) { fsState.copies.push([this.uri, dest.uri]); }
static downloadFileAsync = vi.fn(async () => undefined);
}
class Directory {
uri: string;
name: string;
exists = true;
constructor(parent: { uri: string }, name: string) {
this.name = name;
this.uri = `${parent.uri}${name}/`;
}
create() {}
delete() { fsState.deleted.push(this.uri); }
list() {
return (fsState.listings.get(this.uri) ?? []).filter((e) => !fsState.deleted.includes(e.uri));
}
}
return {
File,
Directory,
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(),
}));
const { jmapClientMock } = vi.hoisted(() => ({
jmapClientMock: {
authHeader: 'Bearer token',
ensureFreshToken: vi.fn(async () => undefined),
forceRefreshToken: vi.fn(async () => false),
},
}));
vi.mock('../../api/jmap-client', () => ({
jmapClient: jmapClientMock,
}));
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 { File, Directory } from 'expo-file-system';
import { getDownloadUrl } from '../../api/blob';
import {
shareAttachment, downloadAttachment, cachePreviewFile, discardPreviewFile, saveLocalFileCopy,
shareLocalFile,
} 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/bulwark-exports/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('refreshes the token before downloading and retries once after a 401', async () => {
const { File } = await import('expo-file-system');
jmapClientMock.ensureFreshToken.mockClear();
jmapClientMock.forceRefreshToken.mockClear().mockResolvedValueOnce(true);
vi.mocked(File.downloadFileAsync).mockClear()
.mockRejectedValueOnce(new Error('Download failed: 401'))
.mockResolvedValueOnce(undefined as never);
await shareAttachment('blob-6', 'report.pdf', 'application/pdf');
expect(jmapClientMock.ensureFreshToken).toHaveBeenCalledTimes(1);
expect(jmapClientMock.forceRefreshToken).toHaveBeenCalledTimes(1);
expect(File.downloadFileAsync).toHaveBeenCalledTimes(2);
expect(IntentLauncher.startActivityAsync).toHaveBeenCalledTimes(1);
});
it('gives up when the 401 persists after a refresh', async () => {
const { File } = await import('expo-file-system');
jmapClientMock.forceRefreshToken.mockClear().mockResolvedValueOnce(false);
vi.mocked(File.downloadFileAsync).mockRejectedValueOnce(new Error('Download failed: 401'));
await expect(shareAttachment('blob-7', 'x.pdf', 'application/pdf')).rejects.toThrow('401');
});
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/bulwark-exports/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',
});
});
});
describe('cachePreviewFile (in-app preview)', () => {
beforeEach(() => {
fsState.deleted.length = 0;
vi.mocked(File.downloadFileAsync).mockClear();
vi.mocked(getDownloadUrl).mockClear();
});
it('downloads into a folder of its own, keeping the real name and the owning account', async () => {
const file = await cachePreviewFile('blob-p', 'report.pdf', 'application/pdf', 'acct-2');
expect(getDownloadUrl).toHaveBeenCalledWith('blob-p', 'report.pdf', 'application/pdf', 'acct-2');
expect(file.uri).toMatch(/^file:\/\/\/cache\/bulwark-exports\/preview-[^/]+\/report\.pdf$/);
expect(File.downloadFileAsync).toHaveBeenCalledWith(
'https://mail.example.com/blob-p/report.pdf',
file,
{ headers: { Authorization: 'Bearer token' }, idempotent: true },
);
});
it('never reuses a path, so two files with one name do not share an image cache entry', async () => {
const a = await cachePreviewFile('blob-a', 'photo.jpg', 'image/jpeg');
const b = await cachePreviewFile('blob-b', 'photo.jpg', 'image/jpeg');
expect(a.name).toBe('photo.jpg');
expect(b.name).toBe('photo.jpg');
expect(a.uri).not.toBe(b.uri);
});
it('makes the name safe for the file system', async () => {
const file = await cachePreviewFile('blob-n', 'a:b?.txt', 'text/plain');
expect(file.name).toBe('a_b_.txt');
});
it('removes its folder and rethrows when the download fails', async () => {
vi.mocked(File.downloadFileAsync).mockRejectedValueOnce(new Error('Download failed: 404'));
await expect(cachePreviewFile('blob-x', 'gone.txt', 'text/plain')).rejects.toThrow('404');
expect(fsState.deleted).toHaveLength(1);
expect(fsState.deleted[0]).toMatch(/\/bulwark-exports\/preview-[^/]+\/$/);
});
});
describe('discardPreviewFile', () => {
beforeEach(() => {
fsState.deleted.length = 0;
});
it('deletes the preview file together with its folder', async () => {
const file = await cachePreviewFile('blob-d', 'notes.txt', 'text/plain');
discardPreviewFile(file);
expect(fsState.deleted).toEqual([file.uri.replace(/notes\.txt$/, '')]);
});
it('only deletes the file itself outside a preview folder', () => {
const exports = new Directory({ uri: 'file:///cache/' } as never, 'bulwark-exports');
const file = new File(exports as never, 'shared.zip');
discardPreviewFile(file);
expect(fsState.deleted).toEqual(['file:///cache/bulwark-exports/shared.zip']);
});
});
describe('saveLocalFileCopy (Download from a preview)', () => {
beforeEach(() => {
platform.OS = 'android';
fsState.copies.length = 0;
vi.mocked(File.downloadFileAsync).mockClear();
vi.mocked(IntentLauncher.startActivityAsync).mockClear();
vi.mocked(Sharing.shareAsync).mockClear();
});
it('copies the file into documents and offers the share sheet without downloading again', async () => {
const preview = await cachePreviewFile('blob-s', 'report.pdf', 'application/pdf');
vi.mocked(File.downloadFileAsync).mockClear();
await saveLocalFileCopy(preview, 'application/pdf');
expect(fsState.copies).toEqual([[preview.uri, 'file:///documents/report.pdf']]);
expect(File.downloadFileAsync).not.toHaveBeenCalled();
expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///documents/report.pdf', {
mimeType: 'application/pdf',
dialogTitle: 'report.pdf',
});
});
});
describe('shareLocalFile', () => {
beforeEach(() => {
platform.OS = 'ios';
fsState.deleted.length = 0;
vi.mocked(Sharing.shareAsync).mockClear();
});
it('removes the file after sharing by default', async () => {
const file = new File({ uri: 'file:///cache/bulwark-exports/' } as never, 'bundle.zip');
await shareLocalFile(file, 'application/zip');
expect(Sharing.shareAsync).toHaveBeenCalledTimes(1);
expect(fsState.deleted).toEqual([file.uri]);
});
it('keeps the file an open preview still shows', async () => {
const file = new File({ uri: 'file:///cache/bulwark-exports/preview-1/' } as never, 'photo.jpg');
await shareLocalFile(file, 'image/jpeg', 'photo.jpg', { forceSheet: true, keep: true });
expect(Sharing.shareAsync).toHaveBeenCalledTimes(1);
expect(fsState.deleted).toEqual([]);
});
});
// Last: it reloads the module to get a process that has not swept yet.
describe('sweepStaleExportFiles', () => {
it('clears stale files, and preview folders once they are empty', async () => {
vi.resetModules();
const fsMod = await import('expo-file-system');
const { sweepStaleExportFiles } = await import('../email-export');
fsState.deleted.length = 0;
fsState.listings.clear();
const now = Date.now();
const day = 24 * 60 * 60 * 1000;
const root = new fsMod.Directory({ uri: 'file:///cache/' } as never, 'bulwark-exports');
const aged = (dir: { uri: string }, name: string, age: number) => {
const f = new fsMod.File(dir as never, name);
(f as unknown as { modificationTime: number }).modificationTime = now - age;
return f;
};
const oldExport = aged(root, 'old.pdf', 2 * day);
const newExport = aged(root, 'new.pdf', 60_000);
const handedOff = new fsMod.Directory(root as never, 'preview-a');
const open = new fsMod.Directory(root as never, 'preview-b');
const other = new fsMod.Directory(root as never, 'keep-me');
fsState.listings.set(root.uri, [oldExport, newExport, handedOff, open, other]);
fsState.listings.set(handedOff.uri, [aged(handedOff, 'photo.jpg', 2 * day)]);
fsState.listings.set(open.uri, [aged(open, 'notes.txt', 60_000)]);
fsState.listings.set(other.uri, [aged(other, 'x.bin', 2 * day)]);
await sweepStaleExportFiles();
expect(fsState.deleted).toEqual([
oldExport.uri,
`${handedOff.uri}photo.jpg`,
handedOff.uri,
]);
});
});