Build live notifications for Android
A basic understanding of Android and JavaScript is needed to follow this tutorial.
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:
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:
let payload = {
graphUrl: "http://www.example.com/path/to/graph.png",
currentPrice: "2387.88",
openPrice: "2371.22",
currencyPair: "BTCUSD"
}
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:
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.
const request = require('sync-request');
let btcprice = JSON.parse(request('GET', 'https://www.bitstamp.net/api/v2/ticker_hour/btcusd/').getBody('utf8'));
let currentPrice = btcprice.last;
let openPrice = btcprice.open;
Now we need to send this as a Push to FCM, using the data
payload.
const Pusher = require('pusher');
const pusher = new Pusher({
appId: '[APP_ID]', //Get these from your Pusher dashboard
key: '[KEY]', //Get these from your Pusher dashboard
secret: '[SECRET]', //Get these from your Pusher dashboard
});
pusher.notify(['BTCUSD'], {
fcm: {
data: payload //We defined the payload above
}
});
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:
const cron = require('node-cron');
const updatePrice = () => {
let btcprice = JSON.parse(request('GET', 'https://www.bitstamp.net/api/v2/ticker_hour/btcusd/').getBody('utf8'));
let currentPrice = btcprice.last;
let openPrice = btcprice.open;
let currencyPair = "BTCUSD";
let payload = {
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&",
currentPrice: currentPrice,
openPrice: openPrice,
currencyPair: currencyPair
}
pusher.notify([currencyPair], {
fcm: {
data: {
graphUrl: graph_url_minute,
currentPrice: currentPrice,
openPrice: openPrice,
currencyPair: currencyPair,
counter: counter
}
}
});
}
//This will run every 15 minutes
var task = cron.schedule('*/15 * * * *', () => {
updatePrice();
});
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
):
<service android:name=".CryptoNotificationsService">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>
We also need to create its class to extend FirebaseMessagingService and implement its onMessageReceived
method:
public class CryptoNotificationsService extends FirebaseMessagingService {
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
...
}
}
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:
Map<String, String> data = remoteMessage.getData();
String graphUrl = data.get("graph_url");
String currentPrice = data.get("currentPrice");
String openPrice = data.get("openPrice");
String 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
:
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="256dp">
<ImageView
android:id="@+id/chart_img"
android:layout_width="wrap_content"
android:layout_height="192dp"
/>
<TextView
android:id="@+id/price_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="24sp"
android:layout_below="@id/chart_img"
android:layout_alignParentStart="true"
android:padding="8dp"
/>
<TextView
android:id="@+id/price_difference_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/chart_img"
android:textSize="24sp"
android:padding="8dp"
android:layout_alignParentEnd="true"
/>
</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
RemoteViews notificationViews = new RemoteViews(getApplicationContext().getPackageName(), R.layout.notification_view);
notificationViews.setTextViewText(R.id.price_text, String.format("%s: %s", currencyPair, currentPrice));
//Some simple view styling:
String arrow = "↑";
if(difference > 0) {
notificationViews.setTextColor(R.id.price_difference_text, getColor(R.color.green));
}
else if(difference == 0){
notificationViews.setTextColor(R.id.price_difference_text, getColor(R.color.black));
arrow = "";
}
else{
notificationViews.setTextColor(R.id.price_difference_text, getColor(R.color.red));
arrow = "↓";
}
notificationViews.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:
int notificationId = 1;
Notification notification = new NotificationCompat.Builder(this)
.setSmallIcon(R.drawable.ic_show_chart_black_24px)
.setCustomBigContentView(notificationViews)
.build();
NotificationManager notificationManager =
(NotificationManager) getSystemService(NOTIFICATION_SERVICE);
notificationManager.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
.
compile 'com.github.bumptech.glide:glide:4.0.0-RC1'
annotationProcessor '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.
final NotificationTarget notificationTarget = new NotificationTarget(
this,
R.id.chart_img,
stockViews,
notification,
1);
final Uri uri = Uri.parse(graphUrl);
Glide.get(getApplicationContext()).clearDiskCache();
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
Glide.get(getApplicationContext()).clearMemory();
Glide.with( getApplicationContext() )
.asBitmap()
.load(uri)
.into( notificationTarget );
}
});
The final thing to do is to subscribe to our interest with Pusher. We named it “BTCUSD”.
final PusherAndroid pusher = new PusherAndroid("[PUSHER_KEY]");
PushNotificationRegistration nativePusher = pusher.nativePusher();
try {
nativePusher.registerFCM(this);
nativePusher.subscribe("BTCUSD");
} catch (ManifestValidator.InvalidManifestException e) {
e.printStackTrace();
}
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. 🎉
2 August 2017
by Zan Markan