Build Apps with AngularJS

This guide gets you started building Chrome Apps with the AngularJS MVC framework. To illustrate Angular in action, we'll be referencing an actual app built using the framework, the Google Drive Uploader. The source code is available on GitHub.

About the app

Google Drive Uploader

The Google Drive Uploader allows users to quickly view and interact with files stored in their Google Drive account as well as upload new files using the HTML Drag and Drop APIs. It's a great example of building an app which talks to one of Google's APIs; in this case, the Google Drive API.

Note: You can also build apps which talk to 3rd party APIs/services that are OAuth2-enabled. See non-Google Account authentication.

The Uploader uses OAuth2 to access the user's data. The chrome.identity API handles fetching an OAuth token for the logged-in user, so the hard work is done for us! Once we have a long-lived access token, the apps uses the Google Drive API to access the user's data.

Key features this app uses:

Creating the manifest

All Chrome Apps require a manifest.json file which contains the information Chrome needs to launch the app. The manifest contains relevant metadata and lists any special permissions the app needs to run.

A stripped down version of the Uploader's manifest looks like this:

{
  "name": "Google Drive Uploader",
  "version": "0.0.1",
  "manifest_version": 2,
  "oauth2": {
    "client_id": "665859454684.apps.googleusercontent.com",
    "scopes": [
      "https://www.googleapis.com/auth/drive"
    ]
  },
 ...
  "permissions": [
    "https://docs.google.com/feeds/",
    "https://docs.googleusercontent.com/",
    "https://spreadsheets.google.com/feeds/",
    "https://ssl.gstatic.com/",
    "https://www.googleapis.com/"
  ]
}

The most important parts of this manifest are the "oauth2" and "permissions" sections.

The "oauth2" section defines the required parameters by OAuth2 to do its magic. To create a "client_id", follow the instructions in Get your client id. The "scopes" list the authorization scopes that the OAuth token will be valid for (for example, the APIs the app wants to access).

The "permissions" section includes URLs that the app will access via XHR2. The URL prefixes are required in order for Chrome to know which cross-domain requests to allow.

Creating the event page

All Chrome Apps require a background script/page to launch the app and respond to system events.

In its background.js script, Drive Uploader opens a 500x600px window to the main page. It also specifies a minimum height and width for the window so the content doesn't become too crunched:

chrome.app.runtime.onLaunched.addListener(function(launchData) {
  chrome.app.window.create('../main.html', {
    id: "GDriveExample",
    bounds: {
      width: 500,
      height: 600
    },
    minWidth: 500,
    minHeight: 600,
    frame: 'none'
  });
});

The window is created as a chromeless window (frame: 'none'). By default, windows render with the OS's default close/expand/minimize bar:

Google Drive Uploader with no frame

The Uploader uses frame: 'none' to render the window as a "blank slate" and creates a custom close button in main.html:

Google Drive Uploader with custom frame

The entire navigational area is wrapped in a <nav> (see next section). To declutter the app a bit, the custom close button is hidden until the user interacts with this the area:

<style>
nav:hover #close-button {
  opacity: 1;
}

#close-button {
  float: right;
  padding: 0 5px 2px 5px;
  font-weight: bold;
  opacity: 0;
  -webkit-transition: all 0.3s ease-in-out;
}
</style>
<button class="btn" id="close-button" title="Close">x</button>

In app.js, this button is hooked up to window.close().

Designing the app the Angular way

Angular is an MVC framework, so we need to define the app in such a way that a model, view, and controller logically fall out of it. Luckily, this is trivial when using Angular.

The View is the easiest, so let's start there.

Creating the view

main.html is the "V" in MVC; where we define HTML templates to render data into. In Angular, templates are simple blocks of HTML with some special sauce.

Ultimately we want to display the user's list of files. For that, a simple <ul> list makes sense. The Angular bits are highlighted in bold:

<ul>
  <li data-ng-repeat="doc in docs">
    <img data-ng-src="{{doc.icon}}"> <a href="{{doc.alternateLink}}">{{doc.title}}</a>
{{doc.size}}
    <span class="date">{{doc.updatedDate}}</span>
  </li>
</ul>

This reads exactly as it looks: stamp out an <li> for every doc in our data model "docs". Each item contains a file icon, link to open the file on the web, and last updatedDate.

Note: To make the template valid HTML, we're using data-* attributes for Angular's ngRepeat iterator, but you don't have to. You could easily write the repeater as <li ng-repeat="doc in docs">.

Next, we need to tell Angular which controller will oversee this template's rendering. For that, we use the ngController directive to tell the DocsController to have reign over the template <body>:

<body data-ng-controller="DocsController">
<section id="main">
  <ul>
    <li data-ng-repeat="doc in docs">
      <img data-ng-src="{{doc.icon}}"> <a href="{{doc.alternateLink}}">{{doc.title}}</a> {{doc.size}}
      <span class="date">{{doc.updatedDate}}</span>
    </li>
  </ul>
</section>
</body>

Keep in mind, what you don't see here is us hooking up event listeners or properties for data binding. Angular is doing that heavy lifting for us!

The last step is to make Angular light up our templates. The typical way to do that is include the ngApp directive all the way up on <html>:

<html data-ng-app="gDriveApp">

You could also scope the app down to a smaller portion of the page if you wanted to. We only have one controller in this app, but if we were to add more later, putting ngApp on the topmost element makes the entire page Angular-ready.

The final product for main.html looks something like this:

<html data-ng-app="gDriveApp">
<head>
  …
  
  <base target="_blank">
</head>
<body data-ng-controller="DocsController">
<section id="main">
  <nav>
    <h2>Google Drive Uploader</h2>
    <button class="btn" data-ng-click="fetchDocs()">Refresh</button>
    <button class="btn" id="close-button" title="Close"></button>
  </nav>
  <ul>
    <li data-ng-repeat="doc in docs">
      <img data-ng-src="{{doc.icon}}"> <a href="{{doc.alternateLink}}">{{doc.title}}</a>  {{doc.size}}
      <span class="date">{{doc.updatedDate}}</span>
    </li>
  </ul>
</section>

A word on Content Security Policy

Unlike many other JS MVC frameworks, Angular v1.1.0+ requires no tweaks to work within a strict CSP. It just works, out of the box!

However, if you're using an older version of Angular between v1.0.1 and v1.1.0, you'll need tell Angular to run in a "content security mode". This is done by including the ngCsp directive alongside ngApp:

<html data-ng-app data-ng-csp>

Handling authorization

The data model isn't generated by the app itself. Instead, it's populated from an external API (the Google Drive API). Thus, there's a bit of work necessary in order to populate the app's data.

Before we can make an API request, we need to fetch an OAuth token for the user's Google Account. For that, we've created a method to wrap the call to chrome.identity.getAuthToken() and store the accessToken, which we can reuse for future calls to the Drive API.

GDocs.prototype.auth = function(opt_callback) {
  try {
    chrome.identity.getAuthToken({interactive: false}, function(token) {
      if (token) {
        this.accessToken = token;
        opt_callback && opt_callback();
      }
    }.bind(this));
  } catch(e) {
    console.log(e);
  }
};

Note: Passing the optional callback gives us the flexibility of knowing when the OAuth token is ready.

Note: To simplify things a bit, we've created a library, gdocs.js to handle API tasks.

Once we have the token, it's time to make requests against the Drive API and populate the model.

Skeleton controller

The "model" for the Uploader is a simple array (called docs) of objects that will get rendered as those <li>s in the template:

var gDriveApp = angular.module('gDriveApp', []);

gDriveApp.factory('gdocs', function() {
  var gdocs = new GDocs();
  return gdocs;
});

function DocsController($scope, $http, gdocs) {
  $scope.docs = [];

  $scope.fetchDocs = function() {
     ...
  };

  // Invoke on ctor call. Fetch docs after we have the oauth token.
  gdocs.auth(function() {
    $scope.fetchDocs();
  });

}

Notice that gdocs.auth() is called as part of the DocsController constructor. When Angular's internals create the controller, we're insured to have a fresh OAuth token waiting for the user.

Fetching data

Template laid out. Controller scaffolded. OAuth token in hand. Now what?

It's time to define the main controller method, fetchDocs(). It's the workhorse of the controller, responsible for requesting the user's files and filing the docs array with data from API responses.

$scope.fetchDocs = function() {
  $scope.docs = []; // First, clear out any old results

  // Response handler that doesn't cache file icons.
  var successCallback = function(resp, status, headers, config) {
    var docs = [];
    var totalEntries = resp.feed.entry.length;

    resp.feed.entry.forEach(function(entry, i) {
      var doc = {
        title: entry.title.$t,
        updatedDate: Util.formatDate(entry.updated.$t),
        updatedDateFull: entry.updated.$t,
        icon: gdocs.getLink(entry.link,
                            'http://schemas.google.com/docs/2007#icon').href,
        alternateLink: gdocs.getLink(entry.link, 'alternate').href,
        size: entry.docs$size ? '( ' + entry.docs$size.$t + ' bytes)' : null
      };

      $scope.docs.push(doc);

      // Only sort when last entry is seen.
      if (totalEntries - 1 == i) {
        $scope.docs.sort(Util.sortByDate);
      }
    });
  };

  var config = {
    params: {'alt': 'json'},
    headers: {
      'Authorization': 'Bearer ' + gdocs.accessToken,
      'GData-Version': '3.0'
    }
  };

  $http.get(gdocs.DOCLIST_FEED, config).success(successCallback);
};

fetchDocs() uses Angular's $http service to retrieve the main feed over XHR. The oauth access token is included in the Authorization header along with other custom headers and parameters.

The successCallback processes the API response and creates a new doc object for each entry in the feed.

If you run fetchDocs() right now, everything works and the list of files shows up:

Fetched list of files in Google Drive Uploader

Woot!

Wait,...we're missing those neat file icons. What gives? A quick check of the console shows a bunch of CSP-related errors:

CSP errors in developer console

The reason is that we're trying to set the icons img.src to external URLs. This violates CSP. For example: https://ssl.gstatic.com/docs/doclist/images/icon_10_document_list.png. To fix this, we need to pull in these remote assets locally to the app.

Importing remote image assets

For CSP to stop yelling at us, we use XHR2 to "import" the file icons as Blobs, then set the img.src to a blob: URL created by the app.

Here's the updated successCallback with the added XHR code:

var successCallback = function(resp, status, headers, config) {
  var docs = [];
  var totalEntries = resp.feed.entry.length;

  resp.feed.entry.forEach(function(entry, i) {
    var doc = {
      ...
    };

    $http.get(doc.icon, {responseType: 'blob'}).success(function(blob) {
      console.log('Fetched icon via XHR');

      blob.name = doc.iconFilename; // Add icon filename to blob.

      writeFile(blob); // Write is async, but that's ok.

      doc.icon = window.URL.createObjectURL(blob);

      $scope.docs.push(doc);

      // Only sort when last entry is seen.
      if (totalEntries - 1 == i) {
        $scope.docs.sort(Util.sortByDate);
      }
    });
  });
};

Now that CSP is happy with us again, we get nice file icons:

Google Drive Uploader with file icons

Going offline: caching external resources

The obvious optimization that needs to be made: not make 100s of XHR requests for each file icon on every call to fetchDocs(). Verify this in the Developer Tools console by pressing the "Refresh" button several times. Every time, n images are fetched:

Console log 65: Fetched icon via XHR

Let's modify successCallback to add a caching layer. The additions are highlighted in bold:

$scope.fetchDocs = function() {
  ...

  // Response handler that caches file icons in the filesystem API.
  var successCallbackWithFsCaching = function(resp, status, headers, config) {
    var docs = [];
    var totalEntries = resp.feed.entry.length;

    resp.feed.entry.forEach(function(entry, i) {
      var doc = {
        ...
      };

      // 'https://ssl.gstatic.com/doc_icon_128.png' -> 'doc_icon_128.png'
      doc.iconFilename = doc.icon.substring(doc.icon.lastIndexOf('/') + 1);

      // If file exists, it we'll get back a FileEntry for the filesystem URL.
      // Otherwise, the error callback will fire and we need to XHR it in and
      // write it to the FS.
      var fsURL = fs.root.toURL() + FOLDERNAME + '/' + doc.iconFilename;
      window.webkitResolveLocalFileSystemURL(fsURL, function(entry) {
        doc.icon = entry.toURL(); // should be === to fsURL, but whatevs.
        
        $scope.docs.push(doc); // add doc to model.

        // Only want to sort and call $apply() when we have all entries.
        if (totalEntries - 1 == i) {
          $scope.docs.sort(Util.sortByDate);
          $scope.$apply(function($scope) {}); // Inform angular that we made changes.
        }

      }, function(e) {
        // Error: file doesn't exist yet. XHR it in and write it to the FS.
        
        $http.get(doc.icon, {responseType: 'blob'}).success(function(blob) {
          console.log('Fetched icon via XHR');

          blob.name = doc.iconFilename; // Add icon filename to blob.

          writeFile(blob); // Write is async, but that's ok.

          doc.icon = window.URL.createObjectURL(blob);

          $scope.docs.push(doc);

          // Only sort when last entry is seen.
          if (totalEntries - 1 == i) {
            $scope.docs.sort(Util.sortByDate);
          }
        });

      });
    });
  };

  var config = {
    ...
  };

  $http.get(gdocs.DOCLIST_FEED, config).success(successCallbackWithFsCaching);
};

Notice that in the webkitResolveLocalFileSystemURL() callback we're calling $scope.$apply() when the last entry is seen. Normally calling $apply() isn't necessary. Angular detects changes to data models automagically. However in our case, we have an addition layer of asynchronous callback that Angular isn't aware of. We must explicitly tell Angular when our model has been updated.

On first run, the icons won't be in the HTML5 Filesystem and the calls to window.webkitResolveLocalFileSystemURL() will result in its error callback being invoked. For that case, we can reuse the technique from before and fetch the images. The only difference this time is that each blob is written to the filesystem (see writeFile()). The console verifies this behavior:

Console log 100: Write completed

Upon next run (or press of the "Refresh" button), the URL passed to webkitResolveLocalFileSystemURL() exists because the file has been previously cached. The app sets the doc.icon to the file's filesystem: URL and avoids making the costly XHR for the icon.

Drag and drop uploading

An uploader app is false advertising if it can't upload files!

app.js handles this feature by implementing a small library around HTML5 Drag and Drop called DnDFileController. It gives the ability to drag in files from the desktop and have them uploaded to Google Drive.

Simply adding this to the gdocs service does the job:

gDriveApp.factory('gdocs', function() {
  var gdocs = new GDocs();
  
  var dnd = new DnDFileController('body', function(files) {
    var $scope = angular.element(this).scope();
    Util.toArray(files).forEach(function(file, i) {
      gdocs.upload(file, function() {
        $scope.fetchDocs();
      });
    });
  });

  return gdocs;
});

Back to top