Javascript

Create a File Storage Mobile App with NativeScript 5

January 11th, 2019 | By Karan Gandhi | 11 min read

Learn how to develop a cross-platform file storage mobile app for Android and iOS using a single NativeScript 5 code base.

In our previous NativeScript article, we previewed the Framework. For this article, we will create a small demo project. To start with, set up a scenario for the demo.

SimpleFileTransfer is a virtual file locker. Users can sign up for the service and get 100 MB of free virtual storage space. Users can then download and upload files to a server. Users can increase their storage space by filling out a form.

Jot down the app's functionalities before moving ahead:

  • Signup: The user can sign up for the app.

  • Login: Authenticates the user.

  • Details Page: Provides user details like the current quota and total space. Also, we can display a list of files.

  • Download file: Download a file from the server to a device.

  • Upload file: Upload files from a device to the server.

  • Increase Quota: Increases the user's storage quota by a specified amount.


You can find the whole code on GitHub.

Structuring the Backend

The backend must provide the functionalities of managing routes, providing basic authentication and storage, and facilitating file transfers.

Based on the requirements above, we will be using the following stack:


We will also be using libraries like multer and bcrypt for specific functionalities. These functionalities will be explained later on.

Initializing the Backend Project

We will be using an Express Generator to set up the project. Install express-generator globally using:

npm install express-generator -g


Start a new project using the command:

express file-server


Navigate to the file-server directory and install the dependencies using npm install. Also, install the following dependencies:

npm install multer async sequelize sqlite3 body-parser bcrypt --save


Additionally, we will be creating some extra folders for:

  • Database: Storing SQLite DB and DB script

  • Model: Storing Models

  • Upload: Temporarily storing uploaded files

  • Storage: storing files for specific users


Starting with Sequelize

Sequelize is an ORM middleware for SQLite, MySQL, PostgreSQL, and MSSQL. For small projects, use the Sequelize and SQLite combo.

In our current scenario, we require only one Model. We will define our model user as follows:

   const User = sequelize.define('user', {
   uid: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
       username:  { type: Sequelize.STRING, unique: true },
       password: Sequelize.STRING,
       quota: {type: Sequelize.INTEGER, defaultValue: 104857600},
       createdAt: Sequelize.DATE,
       updatedAt: Sequelize.DATE,
     })


Use Sequelize's Model.sync to initialize the Models Table in a database. To initialize the user table, we will be using the code below.

     User.sync({force: true}).then(() => {
        // Table created
      });


We will store the user model in the user.js file in the model folder.

Signup and Log in

This part is pretty straightforward. For signup, the server accepts a username and password and stores them in the database.

We will be using the bcrypt library to salt the passwords. As shown below, we are salting the password x10 times before storing it in the database.

We are using Sequelize's Model.create to store the value. Once a user is created, we will create a directory on our server for his uploads.

The code is as below:

      router.post('/', function(req, res, next) {
       console.log(req);
       bcrypt.hash(req.body.password, 10, function(err, hash) {
         User
         .create({ username: req.body.username, password: hash })
         .then(user => {    
         if (!fs.existsSync('storage/'+user.get('uid'))){
         fs.mkdirSync('storage/'+user.get('uid'));
         } 
           res.send({status: true, msg: 'User created', uid: user.get('uid')});
         }).catch(err => {
           res.send({status: false, msg: err });
         })
       });
     });


For login, the server accepts a username and password and validates them against the database. We are using Model.findAll to get the database record. We use bcrypt.compare to compare passwords.

  router.post('/', function(req, res, next) {
     console.log(req);
     User.findAll({
       attributes: ["username", "password"],
       where: {
         username: req.body.username
       }
     }).then(dbQ => {    
         if(dbQ.length > 0) {
           bcrypt.compare(req.body.password, dbQ[0].dataValues.password, function(err, result) {
             if (result == true){
               res.send({status: true, msg: 'Login Success'});
             } else {
               res.send({status: false, msg: 'Incorrect Password'});
             }            
         });
       } else {
         res.send({status: false, msg: 'User not found'});
       }         
     });
   });


Defining the User's Route

An authenticated user is allowed to perform the following functions:

  • Upload file

  • Download file

  • Get Details

  • Increase quota


Let's define the routes for those functions:

  • Upload: POST /users/:id/upload

  • Download: GET /users/:id/download/:filename

  • Details: GET /users/:id/details

  • Increase Quota: POST /users/:id/increasequota


Uploading a File to the Server

We will be using multer to handle uploads.

The multer library is useful for handling multi-part form data.

Initially, we will upload the file to the uploads folder. Then, the file will be moved to the /storage/uid folder, where uid is the user ID.

    var storage = multer.diskStorage({
     destination: function (req, file, cb) {
       cb(null, 'uploads/')
     },
     filename: function (req, file, cb) {
       cb(null, file.originalname )
     }
   });
  
   router.post('/:id/upload', upload.single('fileparam'), function(req, res, next) {
     if (!req.file) {
       console.log("No file received");
       return res.send({
         success: false,
         msg: "Error Uploading files"
       });
     } else {
       console.log('file received');
       fs.rename('uploads/'+ req.file.originalname, 'storage/'+req.params.id+'/'+req.file.originalname, function (err) {
           if (err) {
                console.log(err);
               return;
           }  
           return res.send({
             success: true,
             msg: "File Uploaded"
           })   
       });   
     }
   });

The upload.single method is used for handling uploads. This route expects a file with the name fileparam in the URL call. This is quickly done by adding a name attribute to an HTML form. We will need the name attribute on the app side.

Download Route

ExpressJS provides us with a function to set the download route, conveniently called download. This is the logic we are following:

  • A user logs into the app.

  • He selects a file and initiates the download.

  • The server receives a request with a user ID and filename.

  • The server sends the file back to the user.


The code for the route is below

    router.get('/:id/download/:filename', function(req, res, next) {
         const file = 'storage/'+req.params.id + '/' + req.params.filename;
         res.download(file);
    });


Increase User Quota Route

We will be invoking Model.update to adjust the quota. By default, we have 104857600 bytes, which is equivalent to 100 MB, assigned to each user.

You can find the query below.

   router.post('/:id/increasequota', function(req, res, next) {
     User.update({
       quota: req.body.quota,
     }, {
       where: {
         uid: req.params.id        
       }
     }).then(response => {
       res.send({status: true, msg: "Quota Increased"});
     }).catch(err => {
       res.send({status: false, msg: err});
     }); 
   });


User Details Route

This is a route that we will be using for fetching multiple types of data, such as:

  • Storage Limit of the User: From the DB

  • Current File Space Occupied: from the /storage/user-id directory

  • Space Remaining: It's just Point 1 and Point 2

  • File List: list of files

We can fetch the storage limit of a user using Model.findAll. For fetching file names and storage space, we are using fs .readdir, fs.stat, and async.

   function getStorageSpace(relpath) {
     let space = 0;
     let fileNames = [];
     let promise = new Promise(function (resolve, reject) {
       fs.readdir(relpath, function (err, items) {
         if (err){
           reject(err);
         }
         fileNames = items;
         let filesArr = items.map(function (val) {
           return relpath + '/' + val;
         });
         async.map(filesArr, fs.stat, function (err, results) {
          
           for (let i = 0; i < results.length; i++) {
             if (err) {
               reject(err);
             }
             space = space + results[i].size;
           }
           resolve({fileNames: fileNames, space: space });
         });
       });
     });
     return promise;
   }
  
   function getStorageLimit(uid){
     let promise = new Promise(function (resolve, reject) {
       User.findAll({
         attributes: ["quota"],
         where: {
           uid: uid
         }
       }).then(dbQ => {
       
         if(dbQ.length < 1) {
           reject("Quota Not Found")
         } else {
           resolve(dbQ[0].dataValues.quota);
         }     
       }).catch(err => {
         reject(err);
       });
     });
     return promise; 
   }

   router.get('/:id/details', function(req, res, next) {
     let it;
     let relpath = 'storage/'+req.params.id;
     Promise.all([getStorageSpace(relpath), getStorageLimit(req.params.id)]).then(result => {
      
       res.send({storageLimit: result[1], occupiedSpace: result[0].space, fileNames: result[0].fileNames, remainingSpace: result[1]- result[0].space});
     })
   });


N.B.: The code works on the assumption that the user is not allowed to create a subdirectory in his folder.

The code for enforcing the storage limits will be discussed later in the article.

NativeScript App

On the app side, we will be taking an alternative approach.

A demo project based on the Angular-Blank template will be shared with users.

A significant part of this article will cover details about plugins and their functionalities.

Consuming Web Services

We are consuming data from simple web services for the Login, Signup, and User Details pages.

As mentioned in the previous article, we can access these web services using the HttpClient Module. The basic steps are as follows:

  • Import NativeScriptHttpClientModule in the page module.

  • Import HttpClient and HttpHeaders into the component or Provider.

  • Consume the URL as you would in an Angular application.

  • We will set the Content-Type header to application/json.


For the JavaScript/TypeScript templates, we can use the NativeScript Core HTTP module. The http. getJson function provides the required framework to consume web services. Alternatively, we can also use the fetch module.

As a response from the server, we will be receiving the uid of a user. After authentication, we must store the uid to allow a mobile user to access the /users/uid route.

Storing Data

The NativeScript framework doesn't have any method to store data persistently. We can add that functionality using plugins. We are going to look at two of these plugins.

  • nativescript-sqlite: This plugin provides an interface for the SQLite library. This works well if your app needs to store a large volume of records. Install it with:

tns plugin add nativescript-sqlite

  • nativescipt-localstorage: This plugin provides a key-value API for string data, similar to window.localstorage. This works well if your app doesn't have a lot of records. Install it with:

tns plugin add nativescript-localstorage


The demo app will be using nativescript-localstorage.

Uploading Files from a Device to a Server

Let's break this functionality into subtasks:

  1. Choose Files from the device.

  2. Get the file path.

  3. Upload File over uploads WebService.



To choose a file and get a file path, we will be using the nativescript-mediapicker plugin. The plugin has multiple modes, and we can customize it for specific use cases. You can check out the plugin documentation.

To select a file, we first need to define its extensions. This is different for both OSes.

For Android devices, we have to use file extensions based on mime types like let extensions = ["xlsx", "xls", "doc", "docx", "ppt", "pptx", "pdf", "txt", "png"]

For iOS devices, we have to define extensions from the list for Unified Type identifiers: let extensions = [kUTTypePDF, kUTTypeText];

You can read more about UTIs.

The code for invoking Filepicker is as below:

    let options: FilePickerOptions = {
       android: {
           extensions: extensions,
           maxNumberFiles: 1
       },
       ios: {
           extensions: extensions,
           multipleSelection: false
       }
   };
  
   let mediafilepicker = new Mediafilepicker();
   mediafilepicker.openFilePicker(options);
  
   `mediafilepicker.on("getFiles", function (res) {
       let results = res.object.get('results');
       console.dir(results);
   });
  
   mediafilepicker.on("error", function (res) {
       let msg = res.object.get('msg');
       console.log(msg);
   });
  
   mediafilepicker.on("cancel", function (res) {
       let msg = res.object.get('msg');
       console.log(msg);
   });`

As above, we will receive the file path of a file in the getFiles event.

We will send the file to the server using the nativescript-background-http plugin. You can read about the plugin.

Earlier, we defined the /users/:id/upload route. As mentioned earlier, our server is expecting the file in the attribute named fileparam.

The background HTTP provides us with two functions: uploadFile and multipartUpload. Since we need to set the name attribute, we will be using the multiPartUpload function.

    let session = bgHttp.session("image-upload");
    let request: bgHttp.Request = {
        url: Config.apiUrl  + '/users/' + localStorage.getItem('uid') + '/upload'   ,
        method: "POST",
        headers: {
            "Content-Type": "multipart/form-data"
        },
        description: 'FileName'
    };
    let params = [{
        name: 'file',
        filename: path
    }];
    let task: bgHttp.Task = session.multipartUpload(params, request);
    task.on("error", (e) => {
        reject(e);
    });
    task.on("complete", (e) => {
        resolve(e);
    }); 


Downloading a File to the Device

We will be using the core file-system, platform, and utils modules to achieve the result. Both Android and iOS handle downloads differently. We will be using the isAndroid and isIOS variables from the platform module to segregate the code.

The file-system module provides us with a knownFolders sub-module. Three predefined folders for both Android and iOS are available:

  • knownFolders.currentApp()

  • knownFolders.documents()

  • knownFolders.temp()



Additionally, an iOS sub-module provides us with some other predefined folders. E.g:

  • knownFolders.ios.download

  • knownFolders.ios.sharedPublic

iOS Code

In an iOS scenario, this is straightforward:

  • Show a list of server files.

  • Download the files to the Documents folder.

  • List downloaded files in a separate view

  • Use the utils.openFile function to open the file.



To download the files, we will be using the HTT module of the NativeScript framework. The getFile function can be used to fetch files from the server and save them to a specific file location. The snippet for iOS is below:

     let filePath: string = path.join(knownFolders.documents().path, fileName);
           getFile(download_url + fileName, filePath).then((resultFile) => {
                   // The returned result will be File object
   }, (e) => {
       console.log(e);

Once the file has been downloaded, we can use the openFile function from the utils module to open a file on iOS.

Android Code

The Android side of coding is a bit trickier. The locations of the knownFolders module are as below:

  • currentFolder: /data/data/:appid/files/app

  • documents: /data/user/:androiduser/:appid/files

  • temp: /data/user/:androiduser/:appid/cache



As you can see, all the folders are located in /data. /data is inaccessible to users. Furthermore, external apps won't be able to access the files in those folders. Also, there is no openFile function for Android.

As of now, the best we can do is:

  • Show a list of server files.

  • Download a file to a user-accessible location.

  • List the files present at the location.



To implement the functionality, we will be using a bit of native code.
Before moving ahead, we will have to install tns-platform-declarations with:

npm i tns-platform-declarations --save


Create a reference.d.ts file in the root folder and add the following lines:

`/// <reference path="./node_modules/tns-platform-declarations/ios.d.ts" />`
`/// <reference path="./node_modules/tns-platform-declarations/android.d.ts" />`


Android OS provides us with a function to access external storage.

We will use the constant DIRECTORY_DOWNLOADS and the function getExternalStoragePublicDirectory to create a publicly accessible download location.

We will append the path “SimpleFileTransfer” to create a custom folderPath and filePath.

   const androidDownloadsPath = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS).toString();
   const androidFolderPath = fs.path.join(androidDownloadsPath, "SimpleFileTransfer");
   const filePath: string = fs.path.join(androidFolderPath, fileName);
   getFile(download_url + fileName, filePath).then((resultFile) => {
                   // The returned result will be File object
   }, (e) => {
       console.log(e);


If you check your file explorer, a new directory will be created in the Downloads folder called SimpleFileTransfer. You will find all the files you downloaded there.

Listing Downloaded Files

We will be using the file-system module.

The Folder class of the file-system module has a getEntities function that allows us to list files in a folder. As with fs.readdir in Node.js, we can only list the files.

For iOS, the path is

const  folderPath:  string  =  fs.knownFolders.documents().path;


For Android, the path is

const androidDownloadsPath  =  android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS).toString();

`const  folderPath=  fs.path.join(androidDownloadsPath, "SimpleFileTransfer");`

To access the Folder functions, we define a folder using

let  internalFolder  =  fs.Folder.fromPath(folderPath);


Then, we use getEntities to get a list of files:

  internalFolder.getEntities()
               .then((entities) => {
                   // entities is array with the document's files and folders.
                  
                   entities.forEach((entity) => {
                   let  fileSize  =  fs.File.fromPath(entity.path).size;
                       this.listArray.push({
                           name: entity.name,
                           path: entity.path,
                           lastModified: entity.lastModified.toString(),
                           size : fileSize
                       });
                   });                  
               }).catch((err) => {
                   // Failed to obtain folder's contents.
                   console.log(err.stack);
               });

Additionally, we have used the size property of File class to get file size.

Enforcing the Storage Limit

The storage limit can be enforced in two ways:

  • Upload the file to the server --> Checking remaining space --> Reject the upload on the server side.

  • Check remaining space using webservice --> Check file size --> Cancel the upload on the app side.


To enforce the former, we can modify the upload route as below:

   Promise.all([getStorageSpace(relpath), getStorageLimit(req.params.id)]).then(result => {
     if (result[1] - result[0].space > req.file.size){
       fs.rename('uploads/'+ req.file.originalname, 'storage/'+req.params.id+'/'+req.file.originalname, function (err) {
         if (err) {
           return res.send({
             success: false,
             msg: "Error Uploading files"
           });
         }  
         return res.send({
           success: true,
           msg: "File Uploaded"
         })   
     });
     } else {
       return res.send({
         success: false,
         msg: "Storage Limit Exceeded"
       });
     } 
     })


To enforce the latter, we get the file size of the file selected by the mediafilepicker plugin and check it against the space remaining using the details web service.

   `let  fileSize  =  fs.File.fromPath(results[0].file).size;`

    if(fileSize < remainingSpace){
    // Send To server
   }`else {
   // alert user about lack of space
   }



Closing Thoughts

This demo covers quite a few different concepts.

We divided the solution into a series of functionalities. We used core NativeScript for UX, interacting with the backend, file system management, and Routing.

We extended the framework by installing plugins for functionalities like picking files. Going further, we used a bit of native code to solve a specific problem.

Using NativeScript allowed us to develop the app faster for both platforms as against individually.

Check out our tutorial if you want to learn how you can secure your NativeScript source code against theft and reverse engineering.

Jscrambler

The leader in client-side Web security. With Jscrambler, JavaScript applications become self-defensive and capable of detecting and blocking client-side attacks like Magecart.

View All Articles

Must read next

Frameworks

Introduction to NativeScript

NativeScript has become one of the most popular frameworks for Hybrid Mobile App development. Learn how to get started with this complete practical guide.

November 14, 2018 | By Karan Gandhi | 6 min read

Web Development

Performance Optimizations in NativeScript

Performance is still a key driver of mobile app engagement. In this guide, we explore some strategies to boost the performance of your NativeScript apps.

November 15, 2019 | By Wern Ancheta | 7 min read

Section Divider