Skip to content
Colin Wren
Twitter

Downloading and Saving binary files using React Native with Expo

JavaScript, Software Development, Testing3 min read

downloading a PDF in expo built app
Printing dialog in my app JiffyCV

A key part of the app I’m building (JiffyCV) is to generate a PDF of the document you’ve been creating, but when it came to implementing the saving of the PDF there wasn’t much documentation so I’m going write some in the hope this helps others looking to add this functionality to their app.

In order to download and save the PDF in my app I’ve had to implement the following flow:

  • Make the request to the server with axios
  • Turn the response’s data in to a base64 string using Buffer
  • Get a path for the file to be saved under
  • Write the file to the path in order to have it available to share
  • Initiate the device’s sharing functionality to share the file
  • The user then has the option to save the file to the file system or share it elsewhere

Libraries used

There are a number of libraries that are needed to implement the flow described above, most are universally needed but axios is more of a personal preference.

The libraries are:

  • axios — For making the requests to server
  • jest-mock-axios — For mocking out the server response during testing
  • buffer — For turning the response from server into base64 string
  • expo-file-system — For saving the file to app’s doc store before we share it
  • expo-sharing — For allowing the user to share / save the file to device

Getting the file from the server

My app is using a serverless function to create a PDF using puppeteer and then returning the PDF object.

The response from the serverless function is base64 encoded but I’m using the arraybuffer responseType of axios which means I need to use buffer to convert these into a base64 string I can use to create the file locally.

This was the only approach with axios that I found worked, however if you’ve got a better way to do this then please leave a comment with your solution.

1import axios from 'axios';
2import {Buffer} from "buffer";
3
4const api = axios.create({
5 baseURL: 'https://your-super-awesome.website.com',
6})
7
8export const headers = {
9 'Content-Type': 'text/html',
10 "Accept": "*/*",
11 "Accept-Encoding": "gzip, deflate, br"
12}
13
14export const options = {
15 headers,
16 responseType: 'arraybuffer',
17}
18
19export const endpoint = '/something/amazing'
20
21export async function grabPdf(data) {
22 const response = await api.post(endpoint, data, options)
23 const buff = Buffer.from(response.data, 'base64')
24 return buff.toString('base64')
25}
Making the request to the server and returning a base64 string of the response’s data

Saving the file to the app’s document store

Once we have the base64 string for the PDF we want to save we need to create the path we’re going to save the file under and save it to the app’s document directory.

You can get the path to save the file under by first getting the app’s document directory using the documentDirectory property from expo-file-system . You then need to append the filename you’ll save the file under.

The document directory will have the / suffixed to it so there’s no need include this in your file name and you’ll need to make sure your filename is URI encoded as if it’s not you may end up with half a filename being shown to the user.

After getting the path to save the file under you can call writeAsStringAsync from expo-file-system passing it the filename, the base64 representation of the file, and as you’re using base64 you’ll need to pass it an options object with the encoding property set to EncodingType.Base64 .

Allowing the user to share / save the file

This part is relatively simple as you just need to pass the path to the saved file in the app’s document store to the shareAsync function of expo-sharing. This will then bring up the dialog to allow the user to save the file to their device or share it via the apps they have installed on their phone.

1import * as FileSystem from "expo-file-system";
2import * as Sharing from "expo-sharing";
3import {grabPdf} from "./api";
4
5function getFileUri(name) {
6 return FileSystem.documentDirectory + `${encodeURI(name)}.pdf`;
7}
8
9export async function generatePdf(data, filename) {
10 const pdf = await grabPdf(data);
11 const fileUri = getFileUri(filename);
12 await FileSystem.writeAsStringAsync(fileUri, pdf, { encoding: FileSystem.EncodingType.Base64 });
13 await Sharing.shareAsync(fileUri);
14}
Saving the file to the app’s document directory and then using that fileUri to allow them to share it

Testing the flow

In order to test the calls to the server with axios you can use the jest-mock-axios library which allows for asserting that an endpoint was called as well as allowing for response values to be asserted against.

The jest-mock-axios way of stubbing server responses isn’t the most elegant solution as it requires you to define the stubbed response after calling the axios code, but once you have the hang of the flow it works well.

When using jest-mock-axios it’s very important to remember to call mockAxios.reset() after each test, as if you don’t the response stubbing won’t work as expected.

1import mockAxios from 'jest-mock-axios';
2import {Buffer} from "buffer";
3import { grabPdf, endpoint, options } from "../api";
4
5const htmlContent = '<marguee>haha the text goes brrrrrrrrrrrr</marguee>';
6const testBuff = Buffer.from('this is a test');
7const testResp = testBuff.toString('base64');
8const expectedBuff = Buffer.from(testResp, 'base64');
9const expectedString = expectedBuff.toString('base64');
10
11describe('grabbing PDF from server', () => {
12 it('Sends the data with the correct headers', () => {
13 grabPdf(htmlContent);
14 expect(mockAxios.post).toHaveBeenCalledWith(endpoint, htmlContent, options);
15 });
16 it('Converts the response to a base64 string', async () => {
17 const pdfCall = grabPdf(htmlContent);
18 mockAxios.mockResponse({ data: testResp });
19 const pdf = await pdfCall;
20 expect(pdf).toBe(expectedString);
21 expect(mockAxios.post).toHaveBeenCalledWith(endpoint, htmlContent, options);
22 });
23 afterEach(() => {
24 mockAxios.reset();
25 })
26});
Testing the calls to the server made via axios

Once the call to the server and its response are mocked then you’ll need to mock the expo-file-system and expo-sharing modules in order to control how the documentDirectory , writeAsStringAsync and shareAsync calls behave.

You can use the standard jest.mock approach for this as shown below. As long as the module mocks return an object with properties for the functions you are calling then it’ll work. If you want to control the way each function behaves or assert the function was called, then using a named mock and use mockImplementation can change the behaviour.

1import mockAxios from "jest-mock-axios";
2import { generatePdf } from "generatePdf";
3import { endpoint } from "api";
4
5const mockSaveFile = jest.fn();
6cosnt mockShareFile = jest.fn();
7
8jest.mock('expo-file-system', () => ({
9 writeAsStringAsync: jest.fn().mockImplementation(() => mockSaveFile()),
10 EncodingType: {
11 Base64: 'base64',
12 },
13 documentDirectory: 'file:///foo/bar/',
14}));
15
16jest.mock('expo-sharing', () => ({
17 shareAsync: jest.fn().mockImplementation(() => mockShareFile()),
18});
19
20describe('Generating the PDF', () => {
21 it('downloads the PDF, saves it and allows the user to share it', async () => {
22 const filename = 'generated file.pdf';
23 const data = 'hi there';
24 const pdf = generatePdf(data, filename);
25 mockAxios.mockResponseFor({url: endpoint},{ data: 'meh' });
26 await pdf;
27 expect(mockSaveFile).toHaveBeenCalled();
28 expect(mockShareFile).toHaveBeenCalled();
29 });
30 afterEach(() => {
31 mockAxios.reset();
32 })
33});
By mocking out the expo-file-system and expo-sharing modules we can make sure they are called after the PDF file is generated.

Summary

Using a combination of expo-file-system and expo-sharing allows for an easy to implement test solution and the ability for the user to decide what they want to do with the file after is a really nice user experience, as they may not always wish to save it but instead send it via another app.