Advertisement

PhoneGap: Build a Feed Reader - Application Logic

by

This is the second part of the series about Audero Feed Reader. In this article, we'll delve into the business logic of our application and provide additional background on the plugins and API used for our project.


1. Plugin & API Overview

The Notification Plugin

At several points within the Audero Feed Reader app we'll use the alert() method of the Notification Plugin. How the alert will be shown really depends on the platform the app will run on. In fact, most of the supported operating systems use a native dialog box, but others, like Bada 2.x, use the classic browser's alert() function, which is less customizable. This method accepts up to four parameters:

  1. message: A string containing the message to be displayed.
  2. alertCallback: A callback to invoke when the alert dialog is dismissed.
  3. title: The title of the dialog (the default value is "alert").
  4. buttonName: The button's text included in the dialog (the default value is "OK")

Keep in mind that Windows Phone 7 and 8 don't have a built-in browser alert. So, if you want to use alert('message');, you have to assign window.alert = navigator.notification.alert.

The InAppBrowser Plugin

In the first part of this series, I mentioned that an interesting point of the credits page is the attribute target="_blank" applied to the links. This section will explain how the openLinksInApp() method of the Application class works.

The InAppBrowser is a web-browser that is shown in your app when you use the window.open call. As I said in the first part, starting from version 2.3.0, it has two new methods: executeScript() and insertCSS(). Currently, this plugin provides the following five methods in total:

  • addEventListener(): Allows the user to listen for three events (loadstart, loadstop, and exit), and to attach a function that runs as soon as those events are fired.
  • removeEventListener(): Used to remove a previously-attached listener.
  • close(): Used to close the InAppBrowser window.
  • executeScript(): Enables injection of JavaScript code into the InAppBrowser window.
  • executeScript(): Enables injection of CSS code into the InAppBrowser window.

If you didn't use Cordova for several months, or if you stick to version 2.0.0, you'll remember that by default it opened external links in the same Cordova WebView that was running the application. Therefore, once an external page was visited the last displayed page was shown exactly as it was before the user left it. From that version on, this is no longer the standard behavior. In fact, external links are now opened using the Cordova WebView if the URL is in your app's white list. URLs that aren't on your white list are opened using the InAppBrowser Plugin (more on this in the documentation). But what does this means practically? It means that if you don't manage the links correctly and if your app's users click a link and then turn back to the application, all the jQuery Mobile or other such enhancements are lost. This happens because all of the CSS and JavaScript files are loaded only in the main page, and the subsequent URLs are loaded using AJAX (the default system adopted by jQuery Mobile).

The fix for this issue is implemented in the openLinksInApp() method. In fact, the solution is to catch the clicks on all the external links by setting the target="_blank" attribute, preventing the unwanted default behavior and opening the links using the window.open() method. In order to work, this solution will require that you set a white list in the configuration file.

The Google Feed API

Before talking about the classes of the Audero Feed Reader, we need to delve into the magical world of the Google Feed API and the Google Feed JSON interface because we'll use them within the core feature of our application. As I pointed out in the first part of this series, the interface parses an RSS or ATOM feed and returns a unified and easy-to-parse JSON object. Of course, we can happily manage this JSON object using JavaScript.

This interface supports two query types: Find Feed and Load Feed. The first searches for feeds based on the given keywords passed as an argument, while the second searches for feeds based on a feed URL supplied. In our application, we'll only use the Load Feed feature.

Each request to this Google API must send at least two parameters: v and q. Yes, they have very cryptic names! The first parameter, v, specifies the protocol version number. At the time of this writing, the only valid value is "1.0". In the second parameter, q, we pass the URL to parse. In addition to these, our application will use also the num parameter. The documentation specifies the number of entries to load from the feed specified by q. A value of -1 indicates the maximum number of entries supported, currently 100. By default, load feed returns four results. So, it's essential to implement our feature of loading 10 entries by default and then increment by another 10 each time the user is required to show more.

Now that you're aware of how we'll query the Google service, it's important to clarify the result it'll return. If the URL we provided was correct, we'll find the entries of the Feed inside the responseData.feed.entries property. Every entry has a lot of information, but we'll use just a few of them. In particular, we'll print the following properties:

  • title: The entry title.
  • link: The URL for the HTML version of the entry.
  • author: The author of the entry.
  • contentSnippet: A snippet of less than 120 characters of the content attribute. The snippet does not contain any HTML tags.

The details I've provided above are enough for the purpose of our application, but if you want to learn more, take a look at the Google Feed documentation.


2. Building the Feed Class

This section will describe the Feed class and its methods, all included in the feed.js file. As I pointed out in the previous part, we'll save only two fields for each feed: the title and the URL. So, this class accepts these two data points as parameters. Inside it, we create two private properties: _db and _tableName. Remember that JavaScript doesn't actually have property and method visibility modifiers, so we're actually emulating private data.

The first is a shortcut for the localStorage property of the window object. It's used to access the methods exposed by the Storage Plugin, on which our app is based, and that we'll use to store the Feeds. The second is a string containing the name of the key where we'll save the data. In fact, recalling the Storage specs, it stores the data using a key-value format. Therefore, to store our array of feeds, we need to JSON-ify it. That's exactly what our save() method will do. In the same way, to retrieve the data we have to parse a JSON string to turn it into an object. This task is achieved by the load() method. Those methods are the only two that need to be inside the class definition because they use private properties.

The relative section of the feed.js file is listed below:

function Feed(name, url) {
   var _db = window.localStorage;
   var _tableName = 'feed';

   this.name = name;
   this.url = url;

   this.save = function (feeds) {
      _db.setItem(_tableName, JSON.stringify(feeds));
   };

   this.load = function () {
      return JSON.parse(_db.getItem(_tableName));
   };
}

Around these two simple methods we'll create a bunch of other common ones. Specifically, we'll build some instance methods such as add(), to add a new Feed, delete(), to delete a feed, and compareTo(), to compare a Feed instance with another Feed. Besides these, we'll also develop some static methods such as getFeeds() to retrieve all the feeds from storage, getFeed() to retrieve just one, and compare() to compare two objects.

The comparison methods are worth a little discussion to understand how we'll compare them. I'll skip the description of compareTo() because it does nothing but call its static counterpart, compare(), that actually does the job. In it, we'll first test if either one of the given values is null-like. In case none of them is null-like, we'll compare lexicographically their name and, in case they're equal, compare their URL. However, as you'll discover later, we'll force the user to never have two feeds with the same name or URL.
The compare() method is important because it defines the way we're comparing two feeds, and this is crucial to establish how the Feeds will be sorted in the list-feeds.html page. In fact, we'll use the native sort() array method that accepts an optional parameter, a function, which defines the sort order of the array based on its return values.

The code that implements what I described is the following:

Feed.prototype.compareTo = function (other) {
   return Feed.compare(this, other);
};

Feed.compare = function (feed, other) {
   if (other == null) {
      return 1;
   }
   if (feed == null) {
      return -1;
   }
   var test = feed.name.localeCompare(other.name);
   return (test === 0) ? feed.url.localeCompare(other.url) : test;
};

In addition to the methods seen so far, we'll create two search methods that we'll use to find and delete a given Feed: searchByName() and searchByUrl(). The last method I want to highlight is getIndex(), and it is the one used to retrieve the index of a certain file.

Now that we have uncovered all the details of this class, I can list the whole source code of the file:

function Feed(name, url) {
   var _db = window.localStorage;
   var _tableName = 'feed';

   this.name = name;
   this.url = url;

   this.save = function (feeds) {
      _db.setItem(_tableName, JSON.stringify(feeds));
   };

   this.load = function () {
      return JSON.parse(_db.getItem(_tableName));
   };
}

Feed.prototype.add = function () {
   var index = Feed.getIndex(this);
   var feeds = Feed.getFeeds();

   if (index === false) {
      feeds.push(this);
   } else {
      feeds[index] = this;
   }

   this.save(feeds);
};

Feed.prototype.delete = function () {
   var index = Feed.getIndex(this);
   var feeds = Feed.getFeeds();


   if (index !== false) {
      feeds.splice(index, 1);
      this.save(feeds);
   }

   return feeds;
};

Feed.prototype.compareTo = function (other) {
   return Feed.compare(this, other);
};

Feed.compare = function (feed, other) {
   if (other == null) {
      return 1;
   }
   if (feed == null) {
      return -1;
   }
   var test = feed.name.localeCompare(other.name);
   return (test === 0) ? feed.url.localeCompare(other.url) : test;
};

Feed.getFeeds = function () {
   var feeds = new Feed().load();
   return (feeds === null) ? [] : feeds;
};

Feed.getFeed = function (feed) {
   var index = Feed.getIndex(feed);
   if (index === false) {
      return null;
   }
   var feed = Feed.getFeeds()[index];
   return new Feed(feed.name, feed.url);
};

Feed.getIndex = function (feed) {
   var feeds = Feed.getFeeds();
   for (var i = 0; i < feeds.length; i++) {
      if (feed.compareTo(feeds[i]) === 0) {
         return i;
      }
   }

   return false;
};

Feed.deleteFeeds = function () {
   new Feed().save([]);
};

Feed.searchByName = function (name) {
   var feeds = Feed.getFeeds();
   for (var i = 0; i < feeds.length; i++) {
      if (feeds[i].name === name) {
         return new Feed(feeds[i].name, feeds[i].url);
      }
   }

   return false;
};

Feed.searchByUrl = function (url) {
   var feeds = Feed.getFeeds();
   for (var i = 0; i < feeds.length; i++) {
      if (feeds[i].url === url) {
         return new Feed(feeds[i].name, feeds[i].url);
      }
   }

   return false;
};

3. Building the Application Class

This section discusses the second and last class of the project, Application, contained inside the application.js file. Its aim is to initialize the layout of the pages, attach events to the app page elements, and use the Feed class to save, load, and fetch feeds.

This class is organized to have the entry point in the initApplication() method. It's called as soon as Cordova has been initialized and its APIs are ready to act. Within this method, we're attaching a specific handler to each page initialization so we can manage the events triggered by their widgets. In it, we'll also call Application.openLinksInApp() for reasons discussed previously. Moreover, to improve the user experience, we'll catch every press of the physical back button (where it does exist) to redirect the user to the home page of our app.

The core function of our application is initShowFeedPage() because it uses the Google Feed JSON interface. Before running the request to the service, we count the number of entries already loaded (currentEntries variable) and calculate how many entries the service has to fetch (entriesToShow variable). Then we'll run the AJAX request, using the jQuery ajax() method, and at the same time we show the page loading widget to the user. When the success callback is executed, we first test if the number of entries returned is the same as the number already shown, in which case we show the message "No more entries to load". Otherwise, we append them to the list and refresh the accordion widget ($list.collapsibleset('refresh')). Along with each entry, we also attach a handler to the button that is created so if the connection is off a message is prompted instead of accessing the page.

Finally, the updateIcons() method will be discussed in the next and final part of the series.

The code that implements the discussed class is listed below:

var Application = {
   initApplication: function () {
      $(document)
         .on('pageinit', '#add-feed-page', function () {
            Application.initAddFeedPage();
         })
         .on('pageinit', '#list-feeds-page', function () {
            Application.initListFeedPage();
         })
         .on('pageinit', '#show-feed-page', function () {
            var url = this.getAttribute('data-url').replace(/(.*?)url=/g, '');
            Application.initShowFeedPage(url);
         })
         .on('pageinit', '#aurelio-page', function () {
            Application.initAurelioPage();
         })
         .on('backbutton', function () {
            $.mobile.changePage('index.html');
         });
      Application.openLinksInApp();
   },
   initAddFeedPage: function () {
      $('#add-feed-form').submit(function (event) {
         event.preventDefault();
         var feedName = $('#feed-name').val().trim();
         var feedUrl = $('#feed-url').val().trim();
         if (feedName === '') {
            navigator.notification.alert('Name field is required and cannot be empty', function () {
            }, 'Error');
            return false;
         }
         if (feedUrl === '') {
            navigator.notification.alert('URL field is required and cannot be empty', function () {
            }, 'Error');
            return false;
         }

         if (Feed.searchByName(feedName) === false && Feed.searchByUrl(feedUrl) === false) {
            var feed = new Feed(feedName, feedUrl);
            feed.add();
            navigator.notification.alert('Feed saved correctly', function () {
               $.mobile.changePage('index.html');
            }, 'Success');
         } else {
            navigator.notification.alert('Feed not saved! Either the Name or the Url specified is already in use', function () {
            }, 'Error');
         }
         return false;
      });
   },
   initListFeedPage: function () {
      var $feedsList = $('#feeds-list');
      var items = Feed.getFeeds();
      var htmlItems = '';

      $feedsList.empty();
      items = items.sort(Feed.compare);
      for (var i = 0; i < items.length; i++) {
         htmlItems += '<li><a href="show-feed.html?url=' + items[i].url + '">' + items[i].name + '</a></li>';
      }
      $feedsList.append(htmlItems).listview('refresh');
   },
   initShowFeedPage: function (url) {
      var step = 10;
      var loadFeed = function () {
         var currentEntries = $('#feed-entries').find('div[data-role=collapsible]').length;
         var entriesToShow = currentEntries + step;
         $.ajax({
            url: 'https://ajax.googleapis.com/ajax/services/feed/load?v=1.0&num=' + entriesToShow + '&q=' + encodeURI(url),
            dataType: 'json',
            beforeSend: function () {
               $.mobile.loading('show', {
                  text: 'Please wait while retrieving data...',
                  textVisible: true
               });
            },
            success: function (data) {
               var $list = $('#feed-entries');
               if (data.responseData === null) {
                  navigator.notification.alert('Unable to retrieve the Feed. Invalid URL', function () {
                  }, 'Error');
                  return;
               }
               var items = data.responseData.feed.entries;


               var $post;
               if (currentEntries === items.length) {
                  navigator.notification.alert('No more entries to load', function () {
                  }, 'Info');
                  return;
               }
               for (var i = currentEntries; i < items.length; i++) {
                  $post = $('<div data-role="collapsible" data-expanded-icon="arrow-d" data-collapsed-icon="arrow-r" data-iconpos="right">');
                  $post
                     .append($('<h2>').text(items[i].title))
                     .append($('<h3>').html('<a href="' + items[i].link + '" target="_blank">' + items[i].title + '</a>')) // Add title
                     .append($('<p>').html(items[i].contentSnippet)) // Add description
                     .append($('<p>').text('Author: ' + items[i].author))
                     .append(
                        $('<a href="' + items[i].link + '" target="_blank" data-role="button">')
                           .text('Go to the Article')
                           .button()
                           .click(function (event) {
                              if (Application.checkRequirements() === false) {
                                 event.preventDefault();
                                 navigator.notification.alert('The connection is off, please turn it on', function () {
                                 }, 'Error');
                                 return false;
                              }
                              $(this).removeClass('ui-btn-active');
                           })
                     );
                  $list.append($post);
               }
               $list.collapsibleset('refresh');
            },
            error: function () {
               navigator.notification.alert('Unable to retrieve the Feed. Try later', function () {
               }, 'Error');
            },
            complete: function () {
               $.mobile.loading('hide');
            }
         });
      };
      $('#show-more-entries').click(function () {
         loadFeed();
         $(this).removeClass('ui-btn-active');
      });
      $('#delete-feed').click(function () {
         Feed.searchByUrl(url).delete();
         navigator.notification.alert('Feed deleted', function () {
            $.mobile.changePage('list-feeds.html');
         }, 'Success');
      });
      if (Application.checkRequirements() === true) {
         loadFeed();
      } else {
         navigator.notification.alert('To use this app you must enable your internet connection', function () {
         }, 'Warning');
      }
   },
   initAurelioPage: function () {
      $('a[target=_blank]').click(function () {
         $(this).closest('li').removeClass('ui-btn-active');
      });
   },
   checkRequirements: function () {
      if (navigator.connection.type === Connection.NONE) {
         return false;
      }

      return true;
   },
   updateIcons: function () {
      var $buttons = $('a[data-icon], button[data-icon]');
      var isMobileWidth = ($(window).width() <= 480);
      isMobileWidth ? $buttons.attr('data-iconpos', 'notext') : $buttons.removeAttr('data-iconpos');
   },
   openLinksInApp: function () {
      $(document).on('click', 'a[target=_blank]', function (event) {
         event.preventDefault();
         window.open($(this).attr('href'), '_blank');
      });
   }
};

Conclusion

In the third and final installment of this series, we'll see how to build and test the installers using the CLI and Adobe PhoneGap Build.

Advertisement