Skip to content

Commit f94f5c4

Browse files
committed
feat(suite-native): enhance BIP329 export handling for cancelled directory picker
1 parent fdd6346 commit f94f5c4

5 files changed

Lines changed: 138 additions & 3 deletions

File tree

suite-native/bip329/jest.config.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Jest configuration for native packages.
3+
* Keeping this file next to the package.json file instead of providing configuration
4+
* with `-c ../../jest.config.native` option in package.json scripts
5+
* allows us to run jest tests directly from IDEs.
6+
*/
7+
const baseConfig = require('../../jest.config.native');
8+
9+
module.exports = {
10+
...baseConfig,
11+
};

suite-native/bip329/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
"scripts": {
99
"depcheck": "yarn g:depcheck",
1010
"type-check": "yarn g:tsc --build",
11-
"lint:js": "yarn g:eslint '**/*.{ts,tsx,js}'"
11+
"lint:js": "yarn g:eslint '**/*.{ts,tsx,js}'",
12+
"test:unit": "yarn g:jest:native",
13+
"test:unit:watch": "yarn g:jest --watch"
1214
},
1315
"dependencies": {
1416
"@suite-common/bip329": "workspace:*",

suite-native/bip329/src/Bip329ExportButton.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export const Bip329ExportButton = ({
5454
}
5555

5656
switch (result.reason) {
57+
case 'cancelled':
58+
// User dismissed the directory picker; nothing to report.
59+
return;
5760
case 'exportFailed':
5861
showToast({
5962
intent: 'critical',
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { Platform } from 'react-native';
2+
3+
import { Directory } from 'expo-file-system';
4+
import * as Sharing from 'expo-sharing';
5+
6+
import { type AllLabelsForAccount } from '@suite-common/suite-sync';
7+
8+
import { exportBip329 } from './exportBip329';
9+
10+
const mockDirectoryWrite = jest.fn();
11+
const mockCreateFile = jest.fn(() => ({ write: mockDirectoryWrite }));
12+
13+
jest.mock('expo-file-system', () => ({
14+
File: jest.fn().mockImplementation(() => ({
15+
exists: false,
16+
create: jest.fn(),
17+
write: jest.fn(),
18+
uri: 'file:///cache/account_labels.jsonl',
19+
})),
20+
Paths: { cache: 'file:///cache' },
21+
Directory: {
22+
pickDirectoryAsync: jest.fn(),
23+
},
24+
}));
25+
26+
jest.mock('expo-sharing', () => ({
27+
shareAsync: jest.fn(),
28+
}));
29+
30+
const mockPickDirectoryAsync = jest.mocked(Directory.pickDirectoryAsync);
31+
const mockShareAsync = jest.mocked(Sharing.shareAsync);
32+
33+
const originalPlatformOS = Platform.OS;
34+
const setPlatformOS = (os: typeof Platform.OS) => {
35+
Platform.OS = os;
36+
};
37+
38+
const LABELS: AllLabelsForAccount = { accountLabel: null, addressLabels: [], outputLabels: [] };
39+
40+
describe('exportBip329', () => {
41+
beforeEach(() => {
42+
jest.clearAllMocks();
43+
});
44+
45+
afterAll(() => {
46+
setPlatformOS(originalPlatformOS);
47+
});
48+
49+
describe('on android', () => {
50+
beforeEach(() => {
51+
setPlatformOS('android');
52+
});
53+
54+
it('writes the file into the picked directory and returns success', async () => {
55+
mockPickDirectoryAsync.mockResolvedValue({ createFile: mockCreateFile } as never);
56+
57+
const result = await exportBip329(null, LABELS);
58+
59+
expect(result).toEqual({ success: true });
60+
expect(mockCreateFile).toHaveBeenCalledTimes(1);
61+
expect(mockDirectoryWrite).toHaveBeenCalledTimes(1);
62+
});
63+
64+
it('returns the cancelled reason when the user dismisses the directory picker', async () => {
65+
mockPickDirectoryAsync.mockRejectedValue(
66+
Object.assign(new Error('The file picker was cancelled by the user'), {
67+
code: 'ERR_PICKER_CANCELLED',
68+
}),
69+
);
70+
71+
const result = await exportBip329(null, LABELS);
72+
73+
expect(result).toEqual({ success: false, reason: 'cancelled' });
74+
});
75+
76+
it('returns the exportFailed reason for any other picker error', async () => {
77+
mockPickDirectoryAsync.mockRejectedValue(new Error('boom'));
78+
79+
const result = await exportBip329(null, LABELS);
80+
81+
expect(result).toEqual({ success: false, reason: 'exportFailed' });
82+
});
83+
});
84+
85+
describe('on ios', () => {
86+
beforeEach(() => {
87+
setPlatformOS('ios');
88+
});
89+
90+
it('shares the cached file and returns success', async () => {
91+
mockShareAsync.mockResolvedValue(undefined);
92+
93+
const result = await exportBip329(null, LABELS);
94+
95+
expect(result).toEqual({ success: true });
96+
expect(mockShareAsync).toHaveBeenCalledWith(
97+
'file:///cache/account_labels.jsonl',
98+
expect.objectContaining({ UTI: 'public.jsonl' }),
99+
);
100+
});
101+
});
102+
103+
describe('on an unsupported platform', () => {
104+
beforeEach(() => {
105+
setPlatformOS('web');
106+
});
107+
108+
it('returns the fileSavingNotSupported reason', async () => {
109+
const result = await exportBip329(null, LABELS);
110+
111+
expect(result).toEqual({ success: false, reason: 'fileSavingNotSupported' });
112+
});
113+
});
114+
});

suite-native/bip329/src/exportBip329.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import { sanitizeFilename } from '@trezor/utils';
99

1010
type ExportBip329Result =
1111
| { success: true }
12-
| { success: false; reason: 'fileSavingNotSupported' | 'exportFailed' };
12+
| { success: false; reason: 'fileSavingNotSupported' | 'exportFailed' | 'cancelled' };
13+
14+
const PICKER_CANCELLED_ERROR_CODE = 'ERR_PICKER_CANCELLED';
1315

1416
const createJsonlContent = (labels: AllLabelsForAccount): string => {
1517
const labelsToExport = suiteSyncToBip329({
@@ -39,7 +41,6 @@ const saveFile = async (fileName: string, content: string): Promise<void> => {
3941
newFile.write(content);
4042
} else if (Platform.OS === 'ios') {
4143
await Sharing.shareAsync(cachedFile.uri, {
42-
mimeType: 'application/jsonl',
4344
UTI: 'public.jsonl',
4445
});
4546
} else {
@@ -61,6 +62,10 @@ export const exportBip329 = async (
6162
return { success: false, reason: 'fileSavingNotSupported' };
6263
}
6364

65+
if (error?.code === PICKER_CANCELLED_ERROR_CODE) {
66+
return { success: false, reason: 'cancelled' };
67+
}
68+
6469
return { success: false, reason: 'exportFailed' };
6570
}
6671

0 commit comments

Comments
 (0)