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:
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.
There's a few things we'll do to make it work:
FirebaseMessagingService
🚀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:
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.
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. 🎉