Urban Airship is a popular messaging and notification service.  In part 1 of this post we’ll look at how to set up Urban Airship push notifications. In part 2 of the post we’ll use these notifications to trigger replications in Couchbase Mobile.
What you’ll need
You can use Urban Airship (UA) for free. (See the UA web site for current pricing details.) You do have to register an account.
- An Urban Airship account
- A Firebase account (requires a Google Login account)
- Android Studio (or Xcode)
Part 1 – Setting up Urban Airship
Bring up an existing Android or iOS project in your IDE (or create a new one). Here I’ll illustrate using Android.
Sign in to your UA account. Navigate to the New App page. Fill in your app name. Select the platforms you want to use. (We’ll pick Android and iOS for this example.) Hitting “Save” will take you to the quickstart quide.  (If you’ve already gone through the quickstart and need a refresher on the setup, you can find the steps for Android here and for iOS here.)
UA quickstart
Here you’re offered a tab for each platform selected. As mentioned, we’ll focus on building for Android this time.
Follow the Urban Airship quickstart steps. I have a few tips for issues I encountered.
Steps 1 & 2 of Urban Airship quickstart
The UA guide refers to the project’s build.gradle script, but then shows modifying the module build.gradle script. I added the UA repository to my project’s gradle script, and the UA dependency to the module gradle script. That worked fine.
Look for the applicationID in the module gradle script.
Step 5 of Urban Airship quickstart
To create your own Application class, right click in the app area of the Project navigation pane and pick New > Java Class. In the popup, set the class name. Then for the Superclass enter android.app.Application.
Don’t forget to update your manifest file. You can use the fully qualified name of your class, or the short form that UA shows. The fully qualified form looks like this:
1 2 |
<application android:name="com.couchbase.cblite.android.cbpushsync.MyApplication" |
Step 6 of Urban Airship quickstart
UA uses Firebase Cloud Messaging (FCM). Step six has a link to further documentation by UA. This new page has detailed instructions on creating an API key for your app.
The instructions on that page under the heading ‘Configure FCM/GCM Service with Urban Airship’ are a little out of date. You’ll find the area to enter your Api Key and Package at the end of the quickstart guide now, instead of where the docs say to look.
In one place, the UA docs refer to the “Google API Project Number”. This is what FCM calls your Sender ID.
Testing
The quickstart ends with a testing area, without much instruction. If you look back at the code included in your Application subclass, you’ll see it’s enabling user notifications. To test, build your app and install it on an emulator or device. (I tried both and they worked fine.)
Enter something into the text field and click “Send Test”. You should see an icon show up in the Android notification area. Oddly, this doesn’t include the text entered into the test area.
For troubleshooting UA directs you to an obscure “Error Console” link in the lower right corner of the documentation page. The link just brought me back to the quickstart guide, unfortunately.
Wrapping up part 1
As you saw in the preceding steps, setting up Urban Airship was pretty quick. The quickstart shows how to send an alert notification. To trigger data sync in a Couchbase Lite client, we’ll want to send a silent alert instead, part 2 of this post will cover that.
Part 2 – Triggering Data Synchronization from the Cloud
In part 1 of this post you setup Urban Airship, in part 2 I will show a sample Android application built with Couchbase Lite that uses an Urban Airship push notification to trigger synchronizing data from the cloud to a mobile device. Here “the cloud” will be a Sync Gateway instance setup in a test configuration. (Couchbase Lite and Sync Gateway are both part of the Couchbase Mobile stack. You can read about Couchbase Mobile here.)
Background
Couchbase Mobile synchronizes data using unidirectional replications. You can set replications to run and end immediately once the local data is up-to-date (“one-shot”), or to listen indefinitely for new changes (“continuous”).
There are a few reasons you might want to use a service like Urban Airship to trigger synchronization instead of setting up a continous replication. For example, a continuous “pull” replication (one that retrieves data from the cloud) needs to keep a network connection open. An open connection will necessarily cause some amount of drain on the device battery, even while inactive. Ideally, an application would only open a connection when there exists changes to transfer.
Beyond that, push notification services like Urban Airship typically have a number of advanced features for addressing groups of devices. UA can give you great flexibility in managing when operations happen. Furthermore, if your app already uses a push service, that already requires its own open network connection. It makes sense to use that to trigger Couchbase Lite instead of doubling up the networking.
This example will show how to integrate Urban Airship. I’ll walk through all the necessary pieces, so you can build a fully working application.
The Application
Here’s a look at the application in action. The upper part of the image shows the app running. The lower part shows a push notification sent from the command line. When the app receives the push notification, it puts up a progress spinner, fires off a one-shot pull replication, then displays the document changes received in a text view.
To understand this example, we’ll take a look at four classes, some of the “glue” that brings them together, and show how to test the results.
Classes Required by Urban Airship
To use Urban Airship (UA), we need to implement two classes, a subclass of Autopilot, and a subclass of AirshipReceiver.
Autopilot
UA has to be initialized before use. You can do that in two ways, either call takeOff
in the application’s onCreate
method or by creating a subclass of Autopilot. I chose to do the latter. As we’ll see, UA has an interesting way of automatically using our subclass.
The class is simple. I don’t want to display anything in the notification bar. I use this class to turn off user notifications, and that’s all.
Here’s the full listing of CBAutoPilot.java.
1 2 3 4 5 6 7 8 9 10 11 |
package com.couchbase.cblite.android.cbpushsync; import com.urbanairship.Autopilot; import com.urbanairship.UAirship; public class CBAutoPilot extends Autopilot { @Override public void onAirshipReady(UAirship airship) { airship.getPushManager().setUserNotificationsEnabled(false); } } |
To use this class, UA employs a technique unique to Android. You indicate which class to use with an entry in your Android manifest file. For my case, the entry looks like this (placed inside the application
section):
1 2 |
<meta-data android:name="com.urbanairship.autopilot" android:value="com.couchbase.cblite.android.cbpushsync.CBAutoPilot"/> |
This is where a bit of Android magic happens. UA includes a Content Provider class to automate the initilization. Content Providers are guaranteed to get instantiated before the application’s onCreate
method gets called. UA uses that to include code to read this meta-data entry, instantiate the subclass, and call the onAirshipReady
method.
AirshipReceiver
With UA initialized and ready to go, we need to provide the hooks to capture and process notifications. To do that, implement a subclass of AirshipReceiver. Again, the class is straightforward.
Here’s the full listing of CBAirshipReceiver.java.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
package com.couchbase.cblite.android.cbpushsync; import android.content.Context; import android.support.annotation.NonNull; import android.util.Log; import com.urbanairship.AirshipReceiver; import com.urbanairship.push.PushMessage; public class CBAirshipReceiver extends AirshipReceiver { private static final String TAG = "CBAirshipReceiver"; @Override protected void onPushReceived(Context context, PushMessage message, boolean notificationPosted) { Log.i(TAG, "Received push message. Alert: " + message.getAlert() + ". posted notification: " + notificationPosted); switch(message.getAlert()) { case "start": CBLHelper.getInstance().startReplication(); break; case "stop": CBLHelper.getInstance().stopReplication(); break; case "reset": CBLHelper.getInstance().reset(); break; default: break; } } @Override protected void onChannelCreated(@NonNull Context context, @NonNull String channelId) { Log.i(TAG, "Channel created. Channel Id:" + channelId + "."); } @Override protected void onChannelUpdated(@NonNull Context context, @NonNull String channelId) { Log.i(TAG, "Channel updated. Channel Id:" + channelId + "."); } @Override protected void onChannelRegistrationFailed(Context context) { Log.i(TAG, "Channel registration failed."); } } |
When we get to testing the app, we’ll see how UA lets you set the alert text. I used that here provide a few different remote operations. An alert can start and stop replications. I also included the ability, by sending “reset”, to delete the database. I did that because once you replicate the data, it’s persistent on the device. Shutting down the app doesn’t reset the local database. Rather than always having to add new documents through Sync Gateway to show replication working, I added the ability to wipe the device database and start over.
You’ll notice a few other overridden methods above. Those methods are all declared abstract in the AirshipReceiver class, so we have to include at least a minimal implementation. It turns out, though, that we need to know the device channel ID to send it a message. This channel ID changes often. In a future post, I’ll show how I register the ID so a server-side application can send to the device. For now, it’s useful to log the ID so we can extract it and use it in testing.
The Application Classes
The hooks above get UA up and running, trap notifications, and trigger actions based on their contents. Now let’s take a look at the application itself.
The app consists of one Activity set up so we can see the contents of documents as they get pulled. The other class wraps some of the Couchbase Lite functionality in a helper.
The Main Activity
In the main activity, I want to show a progress bar while replications happen, then display the set of documents sent.
Here’s the full listing of MainActivity.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
package com.couchbase.cblite.android.cbpushsync; import android.os.Bundle; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.Toolbar; import android.util.Log; import android.view.View; import android.widget.ProgressBar; import android.widget.TextView; import com.couchbase.lite.Database; import com.couchbase.lite.Document; import com.couchbase.lite.DocumentChange; import com.couchbase.lite.replicator.Replication; import com.couchbase.lite.replicator.ReplicationState; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; public class MainActivity extends AppCompatActivity { private static String TAG = MainActivity.class.getSimpleName(); private static final ObjectMapper mapper = new ObjectMapper(); private CBLHelper cblHelper = CBLHelper.getInstance(); private TextView documentView; private ProgressBar progressBar; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); documentView = (TextView) findViewById(R.id.documentView); progressBar = (ProgressBar) findViewById(R.id.progressBar); cblHelper.initialize( new Database.ChangeListener() { @Override public void changed(Database.ChangeEvent event) { Log.i(TAG, "Database change called: count - " + event.getChanges().size()); if (!event.isExternal()) { return; } String json = ""; for (final DocumentChange change : event.getChanges()) { if (!change.isCurrentRevision()) { continue; } Document changedDoc = cblHelper.getExistingDocument(change.getDocumentId()); if (changedDoc == null) continue; try { json += mapper.writeValueAsString(changedDoc.getProperties()); } catch (IOException e) { e.printStackTrace(); } } final String text = json; runOnUiThread(new Runnable() { @Override public void run() { documentView.setText(text); } }); } }, new Replication.ChangeListener() { @Override public void changed(Replication.ChangeEvent event) { Log.i(TAG, "Replication change called: " + event.toString()); if (event.getError() != null) return; if (event.getTransition() == null) return; ReplicationState dest = event.getTransition().getDestination(); final int replicationProgress = ((dest == ReplicationState.STOPPING || dest == ReplicationState.STOPPED) ? View.INVISIBLE : View.VISIBLE); runOnUiThread(new Runnable() { @Override public void run() { progressBar.setVisibility(replicationProgress); } }); } } ); } } |
The core of the code here consists of the two change listeners, one for the database, and one for replications. Each listener interface defines a single method changed
.
The Document changes listener lets us track the actual document updates. A replication may change more than one document at a time. The change event returns a list of document IDs. Looping over the document IDs, we retrieve each document directly, translate the JSON contents into a string, and append it to the text to display. The change listeners are called on a background thread. To finish out, we need to manipulate the UI elements on the main thread. This is easily done with the runOnUiThread
method of the Activity.
The other listener allows us to display a busy-wait spinner (indefinite progress bar) depending on the Replication state. You can read more about monitoring replication state here.
The Database Helper Class
I often wrap database functions in a helper class. Since we only need one instance of the helper, I use a singleton pattern. Just like UA, I want the helper available before the main application starts. I use the same technique of putting the code in a Content Provider.
Here’s the full listing of CBLHelper.java.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 |
package com.couchbase.cblite.android.cbpushsync; import android.content.ContentProvider; import android.content.ContentValues; import android.database.Cursor; import android.net.Uri; import android.os.Handler; import android.os.Looper; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.Log; import com.couchbase.lite.CouchbaseLiteException; import com.couchbase.lite.Database; import com.couchbase.lite.Document; import com.couchbase.lite.Manager; import com.couchbase.lite.android.AndroidContext; import com.couchbase.lite.replicator.Replication; import java.io.IOException; import java.net.URL; public class CBLHelper extends ContentProvider { public static final String TAG = "Push Sync"; private static String DB = "db"; //private static String syncGateway = "http://localhost:4984"; private static String syncGateway = "http://10.0.2.2:4984"; private static URL syncGatewayURL; private Manager manager; private Database database; private Replication puller; private Database.ChangeListener databaseChangeListener; private Replication.ChangeListener replicationChangeListener; private static CBLHelper instance; public static CBLHelper getInstance() { return instance; } @Override public boolean onCreate() { instance = this; enableLogging(); try { syncGatewayURL = new URL(syncGateway + "/" + DB); manager = new Manager(new AndroidContext(getContext()), Manager.DEFAULT_OPTIONS); } catch (IOException ex) { ex.printStackTrace(); } return true; } public void closeDatabase() { database.close(); } public void startReplication() { puller.start(); } public void stopReplication() { puller.stop(); } public void reset() { stopReplication(); try { database.delete(); } catch (CouchbaseLiteException ex) { ex.printStackTrace(); } initialize(databaseChangeListener, replicationChangeListener); } public void initialize(Database.ChangeListener dbl, Replication.ChangeListener rl) { try { database = manager.getDatabase(DB); } catch (CouchbaseLiteException ex) { ex.printStackTrace(); } database.addChangeListener(dbl); databaseChangeListener = dbl; puller = database.createPullReplication(syncGatewayURL); puller.addChangeListener(rl); replicationChangeListener = rl; } public Document getExistingDocument(String documentID) { return database.getExistingDocument(documentID); } // Logging private void enableLogging() { Manager.enableLogging(TAG, Log.VERBOSE); Manager.enableLogging(com.couchbase.lite.util.Log.TAG, Log.VERBOSE); Manager.enableLogging(com.couchbase.lite.util.Log.TAG_SYNC_ASYNC_TASK, Log.VERBOSE); Manager.enableLogging(com.couchbase.lite.util.Log.TAG_SYNC, Log.VERBOSE); Manager.enableLogging(com.couchbase.lite.util.Log.TAG_QUERY, Log.VERBOSE); Manager.enableLogging(com.couchbase.lite.util.Log.TAG_VIEW, Log.VERBOSE); Manager.enableLogging(com.couchbase.lite.util.Log.TAG_DATABASE, Log.VERBOSE); } // Helper function to dispatch on UI thread private void runOnUiThread(Runnable runnable) { new Handler(Looper.getMainLooper()).post(runnable); } // Required overrides @Nullable @Override public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) { return null; } @Nullable @Override public String getType(@NonNull Uri uri) { return null; } @Nullable @Override public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) { return null; } @Override public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { return 0; } @Override public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) { return 0; } } |
You can see this class just forms a thin wrapper. We retain an instance of the class during onCreate
which becomes our singleton. Some other basic setup happens, like enabling logging and creating a database manager. I wrote an initialize
method used to actually open the database, prepare the pull replication, and attach the two change listeners. Recall this is called from our Activity class. (Note: You can have more than one change listener for each type. Couchbase keeps a list of all added listeners, not just the latest one.) The other database methods just provide simple shortcuts.
A content provider has several mandatory methods to implement. We need to provide stubs for those, which you see in the last five methods.
The Android Manifest
Finally, just to show how this ties together and the various parts get exposed, I’ve included the Android manifest file for the project.
Here’s the complete listing of AndroidManifest.xml.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.couchbase.cblite.android.cbpushsync"> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/AppTheme"> <meta-data android:name="com.urbanairship.autopilot" android:value="com.couchbase.cblite.android.cbpushsync.CBAutoPilot"/> <activity android:name=".MainActivity" android:label="@string/app_name" android:theme="@style/AppTheme.NoActionBar"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <receiver android:name=".CBAirshipReceiver" android:exported="false"> <intent-filter> <action android:name="com.urbanairship.push.CHANNEL_UPDATED" /> <action android:name="com.urbanairship.push.OPENED" /> <action android:name="com.urbanairship.push.RECEIVED" /> <action android:name="com.urbanairship.push.DISMISSED" /> <category android:name="${applicationId}" /> </intent-filter> </receiver> <provider android:authorities="${applicationId}.dbhelper" android:exported="false" android:enabled="true" android:name=".CBLHelper" /> </application> </manifest> |
The UA library comes with its own manifest file. The Android build system integrates all the different manifest files to create the final one. The UA library manifest file is where you’ll find the entries for the Content Provider that bootstraps the library.
Sync Gateway
To try the app out, I used Sync Gateway with the built-in walrus database. Walrus is an in-memory database usually used just for testing. This saves the trouble of setting up a back-end Couchbase Server instance.
Here’s the complete listing of the Sync Gateway configuration file.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
{ "log": ["HTTP+"], "adminInterface": "127.0.0.1:4985", "interface": "127.0.0.1:4984", "CORS": { "origin":["*"], "loginorigin":["*"], "headers":["Content-Type"], "maxAge": 1728000 }, "databases": { "db": { "server": "walrus:", "users": { "GUEST": {"disabled": false, "admin_channels": ["*"] } } } } } |
This tells Sync Gateway to listen only on the localhost
interface, accept any cross origin requests, and enables the special GUEST with access to all channels. It’s a pretty good general purpose configuration to start with to make sure everything’s working.
You might have noticed we use 10.0.2.2 as the IP address for Sync Gateway in the Android app. The standard emulator that comes with Android Studio maps this automatically to the hosting machine’s localhost
. If you use a different emulator (Genymotion is another popular one), be sure to look up what IP address to use, since they differ.
If you want to learn more about setting up Sync Gateway on your development machine, or how to work with it from the command line, take a look at this blog series.
Testing
To test everything and record the animation shown at the beginning of the blog, I run both the Android emulator and Sync Gateway on one machine. I then post push notifications to UA through their REST API.
To get something interesting prepared, I first add a document to Sync Gateway. Here’s a shell command to do that.
1 |
$ curl -X PUT -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{ "test" : "UA", "channels": "public" }' http://localhost:4984/db/doc |
(Read more about this here).
With a new document in the database on Sync Gateway, and the app up and running, all that’s left is to trigger a pull replication to see the action.
Here’s the curl
command to send a “start” signal. Recall I pull the value from the “alert” part of the notification to trigger actions.
1 |
$ curl https://go.urbanairship.com/api/push -u 'appKey:appMasterSecret' -X POST -H 'Content-Type: application/json' -H 'Accept: application/vnd.urbanairship+json; version=3;' -d '{ "audience": { "android_channel": "3110bc81-1c7b-4ef8-a7a3-7c56aab19f08" }, "notification" : { "alert": "start" }, "device_types": "all" }' |
You’ll need to provide your own application key and application master secret above. You also need to look at the log files to see the channel value to enter for “android_channel”. That changes fairly regularly. Unfortunately UA doesn’t do much to indicate this. If you find push notifications don’t seem to be having any effect, check the channel value.
After posting to UA, you should see a response like this.
1 |
{"ok":true,"operation_id":"2a6c0edf-e5d0-4b4d-b762-ad5c4478c291","push_ids":["1951019a-2b7f-4f1b-8888-e87047a1feec"],"message_ids":[],"content_urls":[]} |
And, finally, to reset to a blank database to try again, you can post this.
1 |
$ curl https://go.urbanairship.com/api/push -u 'appKey:appMasterSecret' -X POST -H 'Content-Type: application/json' -H 'Accept: application/vnd.urbanairship+json; version=3;' -d '{ "audience": { "android_channel": "3110bc81-1c7b-4ef8-a7a3-7c56aab19f08" }, "notification" : { "alert": "reset" }, "device_types": "all" }' |
Conclusion
That’s it for getting our Android app prepared to perform replications based on push notifications. Be sure and check out this post on monitoring the Sync Gateway changes feed.
Postscript
Download Couchbase and Sync Gateway here. See our documentation for how to add Couchbase Lite to a project.
Check out more resources on our developer portal and follow us on Twitter @CouchbaseDev.
You can post questions on our forums. And we actively participate on Stack Overflow.
Hit me up on Twitter with any questions, comments, topics you’d like to see, etc. @HodGreeley.
[…] Note: You can read about setting up Urban Airship in this blog post. […]