Learn Ionic Cordova File and Image Uploading By Building an Instagram Style App

Server setup

We first need somewhere to upload photos to, so I’ve created a very simple image upload Node app which you can get here. Simply check out the code, run npm install and npm start and you’ll have an image upload server running on port 3000. Not only can you now submit a form with attached files to /upload-photos, you can also get a list of of filenames from /list-images, and access the image files themselves via /images/file-name-here.jpg. For upload testing, there is also a bare-bones image upload form available at /test-upload where you can upload images without any client app needed. Note that the upload-photos endpoint expects the form data to be named ‘photos’, and you can see on the /test-upload form that the form input is in fact named ‘photos’.

Quick fix

This post is largely about building a service that takes a file path string and turns it into a true File object which you can attach to FormData and POST. If you just want the end result, grab this service, provide it in your app, inject it into wherever your controller/service, and call either getMultipleFiles or getSingleFile with an array of file paths or a single file path respectively. These methods return a promise that will resolve with the full File object(s) which you can then simply attach to your form data and submit. You can also get the full finished example app source here.

With regular JavaScript

While the form submit is the simplest way to upload images, the next is to use that same form element but to use JavaScript to upload the selected files. Let’s see what that looks like:

onFileSelectChange(event) {
  const formData = new FormData();
  Array.from(event.target.files).forEach((file: File) => formData.append('photos', file, file.name));
  this.httpClient.post(`${myurl}/image-upload`, formData);
}

This event fires on the file inputs change event. The event object passed in contains a FileList object as the property target.files. While a FileList does have a list of files, it’s not actually an Array, so we use Array.from on it so we can do a forEach, ultimately just looping through each File object and appending it to the FormData. Notice we name the field appended to the form ‘photos’, as this is what our image upload server is expecting. We now simply post the data.

This code sets the stage for how we will upload photos taken from either the device’s camera or image gallery. Since Ionic and Cordova apps are web based, let’s just test this out first in the web. Create a new Ionic blank starter app, and on your home.html file add an input like so:

<input type="file" multiple (change)="onFileSelectChange($event)" placeholder="upload a from web file"/>

In your home.ts file place the event handler code pasted earlier so you have the proper event handler. Replace myurl with localhost:3000, do an ionic serve and you should be able to upload files from the web.

The Cordova difference

The difference between the simple example shown above and what we get from the phone’s camera or gallery is is simple, both only return either a path to the file or a base64 encoded string. We’ll address the base64 string later, but it is not a good option. The file path is just a string showing the path to the file, not a File object, so we have to use the Cordova File Plugin to turn the path into an actual File object, which we can then attach to our FormData and submit. However…this actually requires a number of asynchronous steps, which leads to the first of many pain points many developers hit. Why? The FileSystem API.

An aside about Ionic Native

Before we go any farther, I have address another point of confusion. Ionic has not done a good job naming their Ionic Native services (to be fair, so has Cordova, they also name their plugin “File”.). This has caused a naming conflict. The File plugin is just named File, which as we saw above is an existing type (we looped through them to attach them to the FormData). To avoid this naming collision, be sure to import the Ionic Native File service with a proper name:

import { File as IonicFileService } from '@ionic-native/file';

The FileSystem API and the Cordova File plugin

The FileSystem API is a (currently) non-standard web API that allows browsers to access a sandboxed area of a user’s file system. Since it’s very new and non-standard many developers haven’t worked with it, however since it is an existing web API and closely resembles mobile app data access, Cordova chose to model their File plugin after this API.

When accessing a file system with the API you will be returned either a FileEntry or DirectoryEntry depending on if you are accessing a file or directory. Both are very similar and extend a base Entry object. While there are many methods to get access we’re concerned with the resolveLocalFilesystemUrl method, which you can pass any path and it will return an Entry object. That Entry object will either be a FileEntry if you pass it a file path, or a DirectoryEntry if you pass it a directory path.

A FileEntry is not a File, it cannot be attached to FormData. However, it does have a method file(), which accepts a callback that is passed a File object. Here’s the next point of confusion devs face, this File object does not contain the actual file data so you cannot attach this file as it is to your FormData. You must pass it to a FileReader and have it read the file into a Blob. Then we have the actual file data, and in fact File is just a specific kind of Blob that has a name and modified date. Since we presumably want to keep the file name, we add these properties and cast the Blob to a File. Let’s look at that code.

async getSingleFile(filePath: string): Promise<File> {
  // Get FileEntry from image path
  const fileEntry: FileEntry = await this.ionicFileService.resolveLocalFilesystemUrl(filePath) as FileEntry;

  // Get File from FileEntry. Again note that this file does not contain the actual file data yet.
  const cordovaFile: IFile = await this.convertFileEntryToCordovaFile(fileEntry);

  // Use FileReader on the File object to populate it with the true file contents.
  return this.convertCordovaFileToJavascriptFile(cordovaFile);
}

private convertFileEntryToCordovaFile(fileEntry: FileEntry): Promise<IFile> {
  return new Promise<IFile>((resolve, reject) => {
    fileEntry.file(resolve, reject);
  })
}

private convertCordovaFileToJavascriptFile(cordovaFile: IFile): Promise<File> {
  return new Promise<File>((resolve, reject) => {
    const reader = new FileReader();
    reader.onloadend = () => {
      if (reader.error) {
        reject(reader.error);
      } else {
        const blob: any = new Blob([reader.result], { type: cordovaFile.type });
        blob.lastModifiedDate = new Date();
        blob.name = cordovaFile.name;
        resolve(blob as File);
      }
    };
    reader.readAsArrayBuffer(cordovaFile);
  });
}

We have our main service method here and two helper methods. One to get the File from the FileEntry, and another that uses that file with a FileReader to get back a fully loaded File. If you’re following along in Ionic create a new file ‘real-file-loader.service.ts’ and add the above code, making sure to also import and inject the ionicFileService, and to Provide it either from your app module or your home component.

Dealing with multiple files

When dealing with multiple files selected from the ImagePicker plugin, the process is identical except that rather than single Promises, we’ll need an array of Promises for each call, which we will wait to resolve before making the next call. This method makes use of the two helper functions already posted.

async getMultipleFiles(filePaths: string[]): Promise<File[]> {
  // Get FileEntry array from the array of image paths
  const fileEntryPromises: Promise<FileEntry>[] = filePaths.map(filePath => {
    return this.ionicFileService.resolveLocalFilesystemUrl(filePath);
  }) as Promise<FileEntry>[];

  const fileEntries: FileEntry[] = await Promise.all(fileEntryPromises);

  // Get a File array from the FileEntry array. NOTE that while this looks like 
  // a regular File, it does not have any actual data in it. Only after we use
  // a FileReader will the File object contain the actual file data
  const cordovaFilePromises: Promise<IFile>[] = fileEntries.map(fileEntry => {
    return this.convertFileEntryToCordovaFile(fileEntry);
  });

  const cordovaFiles: IFile[] = await Promise.all(CordovaFilePromises);

  // Use FileReader on each File object to read the actual file data into the 
  // file object
  const filePromises: Promise<File>[] = cordovaFiles.map(cordovaFile => {
    return this.convertCordovaFileToJavascriptFile(cordovaFile)
  });

  // When this resolves, it will return a list of File objects, just as if you 
  // had used the regular web file input. These can then be appended to FormData 
  // and uploaded.
  return await Promise.all(filePromises);
}

Okay, you can see all the steps are the same except that we build arrays of promises first, then wait for them all to resolve. We do this for each step. You can view the full file here.

The hard part is now over, let’s make the app

We’ve just overcome the biggest challenge of file uploading, now let’s get the Camera and ImagePicker plugins installed per the Ionic Native docs here and here. Be sure to follow the second step as well where you import and provide the services. Now we create a second service ‘image-management.service.ts’ that will handle all of our image operations, not only opening the camera and gallery, but also uploading to and getting the images from our server. Let’s check out our camera and image gallery methods first:

async uploadFromImagePicker(): Promise<any[]> {
  const imagePaths: string[] = await this.imagePicker.getPictures({});
  const imageFiles = await this.realFileLoaderService.getMultipleFiles(imagePaths);
  const formData = new FormData();
  imageFiles.forEach(file => formData.append('photos', file, file.name));
  return this.uploadImages(formData);
}

async uploadFromCamera() {
  const imagePath: string = await this.camera.getPicture(this.cameraOptions);
  const imageFile = await this.realFileLoaderService.getSingleFile(imagePath);
  const formData = new FormData();
  formData.append('photos', imageFile, imageFile.name);
  const result = await this.uploadImages(formData);
  await this.camera.cleanup();
  return result;
}

uploadImages(formData: FormData): Promise<any[]> {
  return this.httpClient.post<any[]>(`${this.baseUrl}/upload-photos`, formData).toPromise();
}

The main difference between the camera and image gallery is that the camera only ever returns a single file path for the single image it takes. Otherwise they are the same, they both return image paths that we pass to our service methods. Once we do that, the process is identical to our vanilla JavaScript solution from earlier, simply build a FormData object, append the Files, and POST the data. The full file also sets the Camera options and sets the baseUrl. If you’re testing on a device you’ll need to change the baseUrl from localhost to your computers IP address, or use a service like ngrok to share your localhost over the internet. You can see the full file here.

While we’re in this service, we need a way to fetch our image files, so let’s add a method for that:

async listImagesOnServer(): Promise<string[]> {
  const imageNames = await this.httpClient.get<string[]>(`${this.baseUrl}/list-images`).toPromise();
  return imageNames.map(imageName => `${this.baseUrl}/images/${imageName}`);
}

And that’s it for data access. Let’s go back to our “home.ts” file and add calls to our service methods that we’ll then hook into button events.

async ngOnInit() {
  this.loadImagePaths();
}

async uploadFromImagePicker() {  
  await this.imageManagementService.uploadFromImagePicker();
  this.loadImagePaths();
}

async uploadFromCamera() {
  await this.imageManagementService.uploadFromCamera();
  this.loadImagePaths();
}

private async loadImagePaths() {
  this.imagePaths = await this.imageManagementService.listImagesOnServer();
}

That’s all there is, we kept all the work in our service. It should be noted that in the example app I’ve added error handling and loading spinners, you can see that in the full file here. We’ve added a method to set a local imagePaths array from our call to our server that returns the list of image files, we’ll use that array in our html file to draw the images on screen. When uploading photos, we wait for the upload to finish, then pull down the images from the server again so our new photo shows up.

The final step, draw the images and buttons on the screen in our html file:

<ion-header primary>
  <ion-navbar>
    <ion-title>Ionicstagram</ion-title>
  </ion-navbar>
</ion-header>

<ion-content>
  <ion-grid>
    <ion-row>
      <div *ngFor="let imagePath of imagePaths" 
           ion-col col-4 class="image"
           [ngStyle]="{'background-image': 'url(' + imagePath + ')'}">
      </div>
    </ion-row>
  </ion-grid>
</ion-content>

<ion-toolbar class="footer">
  <button ion-button icon-end clear (click)="uploadFromCamera()">
    Take Photo <ion-icon name="camera"></ion-icon>
  </button>
  <button ion-button icon-end clear (click)="uploadFromImagePicker()">
    From Gallery <ion-icon name="images"></ion-icon>
  </button>
</ion-toolbar>

A small amount of css is also needed to get the look we want:

.image {
  padding-bottom: 33%;
  background: no-repeat center center;
  background-size: cover;
  border: 1px solid white;
}

ion-header .toolbar-background {
  background-color: color($colors, primary, base);
}

.toolbar-title {
  color: white;
}

.footer {
  position: fixed;
  bottom: 0;
  flex-direction: column;
}

.scroll-content {
  margin-bottom: 56px;
}

What about base64 string or the FileTransfer Plugin?

The FileTransfer plugin is deprecated and won’t be maintained by Cordova any longer, so you should not be using it. Since modern native web technology is capable of uploading and downloading binary files they’ve deemed it unnecessary.

Using a base64 string, an option returned by the Camera and ImagePicker plugins, is a way to send your image over the internet as text. While this is actually very simple, the data transferred is much larger since it’s no longer binary data and is instead a string. Trying to convert such a large binary file to a string can actually crash your app with out of memory errors, and this option is not recommended.

Even if using base64 doesn’t crash your app (on the phones you tested on), this is not a good way to transfer binary data, especially on mobile where there can be limited bandwidth and data caps. To illustrate this difference, I set my phone camera to take a small 640×480 image. The resulting image was only 73kb. When I transferred this image via FormData as binary data it used 73287 bytes, or 73kb as expected. However, if I changed the camera to return a base64 string, it used 174380 bytes, or 174kb, well over double the size. You don’t want to be wasting your users time or data just to save the little bit of extra code it takes to do this properly. Below you can see screenshots from Chrome’s network debug tool, you can see the size as binary data on the left and as a string on the right. Both images sent were identical, only the format of binary vs text differed, as you can see in the Content-Type section.

Wrap Up

That’s it, while not the simplest thing to do, we created a pretty nice looking photo upload app with only about 250 lines of code. Remember you can grab the complete example here and the server code here.  Questions, comments suggestion, know how to make this post even better? Please leave a comment below!