Build live notifications for Android

Introduction

I was fascinated at how the Guardian Media Lab covered the US presidential election last fall. They created what they call a live notification. It's a persistent notification that stays in the drawer, and can change each time it receives new data.

They used it to indicate which candidate was winning, and by how many delegates. You can read more about it and how they created it on their Medium blog.

Today I will show you how to add something similar to your apps. In this concrete example, we'll be building a notification that shows the movement of the price of BitCoin, Ether, or your favourite cryptocurrency.

The end product will look similar to this:

live-notifications-android-example

The technologies we will be using are:

  • Android app as the primary user interface
  • Firebase Cloud Messaging (FCM) as delivery mechanism for Push notifications
  • Pusher Push Notifications service to interact with FCM
  • Node.JS for our server component that will orchestrate sending Push notifications
  • Cryptocurrency APIs:
    • Bitstamp for raw data,
    • and BitcoinCharts for the charts in image format

This tutorial assumes you're familiar with the basics of Android and JavaScript/Node.js, and that you have accounts on Pusher and Firebase. If not, I'll wait. Chop, chop.

Setup

There's a few things we'll do to make it work:

  • Set up the server component that sends the pushes at a regular interval
  • Add Glide library for loading images 🛫
  • Implement a custom FirebaseMessagingService 🚀
  • Create the View for displaying the notification 👀
  • Tie everything together 🎁

Sending the notification

FCM allows us to specify 2 types of payloads - notification and data. They differ in how a push notification is handled when the application is not in the foreground.

Using the notification payload requires less work as Android will automatically show the notification if a push is received when the application is not currently in the foreground.

The data payload gives us more freedom in showing the notification and allows us to style it to our liking. That is the one we will use. You can read more about their differences on FCM documentation.

The data payload takes any combination of primitive key/values. On the device we'll get them as an Android Bundle object using remoteMessage.getData().

Our sample bundle could look like this:

1let payload = {
2  graphUrl: "http://www.example.com/path/to/graph.png",
3  currentPrice: "2387.88",
4  openPrice: "2371.22",
5  currencyPair: "BTCUSD"
6}

As I mentioned, we will get the data from two sources - the current price data from Bitstamp's API, as well as an image of the current price chart - from BitcoinCharts.

The current ticker value can be found here.

To get the image from BitcoinCharts we'll need to be a bit clever and inspect the element with the image in our browser to get its URL. With the interval set to 15 minutes the chart's URL looks like this:

live-notifications-bitcoin-graph

To get the latest price data we can use the sync-request Node library. Making the request synchronously is fine as we are making them on an one-by-one basis.

1const request = require('sync-request');
2let btcprice = JSON.parse(request('GET', 'https://www.bitstamp.net/api/v2/ticker_hour/btcusd/').getBody('utf8'));
3let currentPrice = btcprice.last;
4let openPrice = btcprice.open;

Now we need to send this as a Push to FCM, using the data payload.

1const Pusher = require('pusher');
2const pusher = new Pusher({
3    appId: '[APP_ID]', //Get these from your Pusher dashboard
4    key: '[KEY]', //Get these from your Pusher dashboard
5    secret: '[SECRET]', //Get these from your Pusher dashboard
6});
7
8pusher.notify(['BTCUSD'], {
9  fcm: {
10    data: payload //We defined the payload above
11  }
12});

Last thing to do is to make this run not in a one-off, but as a recurring cron job instead. To do that we can wrap our notify call in a function called updatePrice and use the node-cron library to schedule it:

1const cron = require('node-cron');
2
3const updatePrice = () => {
4  let btcprice = JSON.parse(request('GET', 'https://www.bitstamp.net/api/v2/ticker_hour/btcusd/').getBody('utf8'));
5    let currentPrice = btcprice.last;
6    let openPrice = btcprice.open;
7    let currencyPair = "BTCUSD";
8
9    let payload = {
10      graphUrl: "https://bitcoincharts.com/charts/chart.png?width=940&m=bitstampUSD&SubmitButton=Draw&r=1&i=15-min&c=0&s=&e=&Prev=&Next=&t=W&b=&a1=&m1=10&a2=&m2=25&x=0&i1=&i2=&i3=&i4=&v=1&cv=1&ps=0&l=0&p=0&",
11      currentPrice: currentPrice,
12      openPrice: openPrice,
13      currencyPair: currencyPair
14    }
15
16    pusher.notify([currencyPair], {
17        fcm: {
18            data: {
19                graphUrl: graph_url_minute,
20                currentPrice: currentPrice,
21                openPrice: openPrice,
22                currencyPair: currencyPair,
23                counter: counter
24            }
25        }
26    });
27}
28
29//This will run every 15 minutes
30var task = cron.schedule('*/15 * * * *', () => {
31    updatePrice();
32});

We can then run it via the standard node index.js command.

Implementing the client

If you followed the Pusher quick start guide to setting up push notifications you'll have a simple app that subscribes to an interest. It assumes you use the built in FCMMessagingService and attach a listener using nativePusher.setFCMListener(...). This is perfectly fine if you use the notification FCM payload, as the background pushes will be handled and displayed as notifications by the system. Notifications will also stack one after the other.

For live notifications that technique will not work unfortunately. We want more freedom in displaying the notifications and we want to reuse existing notifications to show updates. We need to implement our own FirebaseMessagingService.

In the AndroidManifest replace the FCMMessagingService declaration with the new one (I called it CryptoNotificationsService):

1<service android:name=".CryptoNotificationsService">
2    <intent-filter>
3        <action android:name="com.google.firebase.MESSAGING_EVENT"/>
4    </intent-filter>
5</service>

We also need to create its class to extend FirebaseMessagingService and implement its onMessageReceived method:

1public class CryptoNotificationsService extends FirebaseMessagingService {
2
3    @Override
4    public void onMessageReceived(RemoteMessage remoteMessage) {
5      ...
6    }
7}

This is where we'll consume the data from the push payload, use it to build the notification object from it and show it in a custom view. We can get the data from the remoteMessage - the keys will be named the same as we named them in our FCM payload:

1Map<String, String> data = remoteMessage.getData();
2String graphUrl = data.get("graph_url");
3String currentPrice = data.get("currentPrice");
4String openPrice = data.get("openPrice");
5String currencyPair = data.get("currencyPair");

It's now time to display the data in a notification.

With the data payload we're handling the notification ourselves. Create a new View layout and make it include one ImageView for the chart, and two TextViews for the price data. Everything will be wrapped in a simple RelativeLayout. The layout size is limited to what Android notification tray limits - so 256dp. I called it notification_view:

1<RelativeLayout
2    xmlns:android="http://schemas.android.com/apk/res/android"
3    android:layout_width="match_parent"
4    android:layout_height="256dp">
5
6    <ImageView
7        android:id="@+id/chart_img"
8        android:layout_width="wrap_content"
9        android:layout_height="192dp"
10        />
11
12    <TextView
13        android:id="@+id/price_text"
14        android:layout_width="wrap_content"
15        android:layout_height="wrap_content"
16        android:textSize="24sp"
17        android:layout_below="@id/chart_img"
18        android:layout_alignParentStart="true"
19        android:padding="8dp"
20        />
21
22    <TextView
23        android:id="@+id/price_difference_text"
24        android:layout_width="wrap_content"
25        android:layout_height="wrap_content"
26        android:layout_below="@id/chart_img"
27        android:textSize="24sp"
28        android:padding="8dp"
29        android:layout_alignParentEnd="true"
30        />
31
32</RelativeLayout>

To inflate the layout in a notification context we'll use RemoteViews. This is a construct that allows us to create views outside of the parent process.

Besides notifications, we can also use them to create the home screen Widgets.

On a RemoteViews object we can call methods such as setTextViewText and setTextColor

1RemoteViews notificationViews = new RemoteViews(getApplicationContext().getPackageName(), R.layout.notification_view);
2notificationViews.setTextViewText(R.id.price_text, String.format("%s: %s", currencyPair, currentPrice));
3
4//Some simple view styling:
5String arrow = "↑";
6if(difference > 0) {
7   notificationViews.setTextColor(R.id.price_difference_text, getColor(R.color.green));
8}
9else if(difference == 0){
10    notificationViews.setTextColor(R.id.price_difference_text, getColor(R.color.black));
11    arrow = "";
12}
13else{
14    notificationViews.setTextColor(R.id.price_difference_text, getColor(R.color.red));
15    arrow = "↓";
16}
17notificationViews.setTextViewText(R.id.price_difference_text, String.format("%.2f %s", difference, arrow));

Now that our view is inflated with some data, we can create and display our Notification object. For that we'll use the NotificationCompat.Builder, and call setCustomBitContentView with the RemoteViews object from the previous step. Also take note of the notificationId. This ensures we will reuse the same notification each time a new push notification gives us new data. Finally we display the notification with the notifiy call on the notificationManager passing in the ID and notification object itself:

1int notificationId = 1;
2Notification notification = new NotificationCompat.Builder(this)
3        .setSmallIcon(R.drawable.ic_show_chart_black_24px)
4        .setCustomBigContentView(notificationViews)
5        .build();
6
7
8NotificationManager notificationManager =
9        (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
10notificationManager.notify(notificationId, notification);

Now that we have created a notification with the data, we also need an image.

Glide is an excellent tool for that. It allows loading images in a RemoteViews object. First, add the library to your app/build.gradle dependencies. At the time of writing, the latest version of Glide is 4.0.0-RC1.

1compile 'com.github.bumptech.glide:glide:4.0.0-RC1'
2annotationProcessor 'com.github.bumptech.glide:compiler:4.0.0-RC1'

Glide has the concept of NotificationTarget where you specify the RemoteViews object and the view ID of an ImageView contained in it. It will then load the image using that target.

We'll load the image from a URL we get in the notification. Note that you might also need to call clearDiskCache to clear the image from the cache - in case it has the same hostname and path as the previous image. This will make it always fetch the new image.

Last thing to note is that a call to Glide.load needs to happen on the main thread. As a push is received outside of the main thread we'll need to ensure we call it there. That's where the new Handler(Looper.getMainLooper()).post(...) comes to play.

1final NotificationTarget notificationTarget = new NotificationTarget(
2                this,
3                R.id.chart_img,
4                stockViews,
5                notification,
6                1);
7
8final Uri uri = Uri.parse(graphUrl);
9Glide.get(getApplicationContext()).clearDiskCache();
10
11new Handler(Looper.getMainLooper()).post(new Runnable() {
12    @Override
13    public void run() {
14        Glide.get(getApplicationContext()).clearMemory();
15        Glide.with( getApplicationContext() )
16                .asBitmap()
17                .load(uri)
18                .into( notificationTarget );
19    }
20});

The final thing to do is to subscribe to our interest with Pusher. We named it "BTCUSD".

1final PusherAndroid pusher = new PusherAndroid("[PUSHER_KEY]");
2PushNotificationRegistration nativePusher = pusher.nativePusher();
3try {
4    nativePusher.registerFCM(this);
5    nativePusher.subscribe("BTCUSD");
6} catch (ManifestValidator.InvalidManifestException e) {
7    e.printStackTrace();
8}

And we're done! After running the app we can see the notifications being shown on the devices and the BitCoin price updating every 15 minutes. 🎉