Sending push notifications from Dart to Android and iOS
You will need Dart, Aqueduct and either Android Studio or XCode installed on your machine.
Introduction
In today’s tutorial we will learn how to send push notifications from a Dart server to Android and iOS using Pusher Beams. In order to trigger events in the server, we’ll make a simple admin user app. The final result will look like this:
When the admin user presses a button, the admin app contacts the Dart server. The Dart server then tells Pusher to send a push notification to users. The first button notifies all users who have subscribed to a Device Interest. The second button notifies a single authenticated user.
Prerequisites
I’m assuming you have some familiarity with Server Side Dart, but if not, no problem. Check out these tutorials first:
- Becoming a backend developer - Part 1: Foundational concepts
- Becoming a backend developer - Part 2: Building the server (Dart section)
- Becoming a backend developer Part 3: Connecting to the server from a mobile app
- Authentication with Server Side Dart
Required software:
If you don’t want to make both an Android app and an iOS app at this time, you can choose just one. I’m assuming you are already familiar with mobile app development.
This tutorial was made using Dart 2.2, Android Studio 3.4, and Xcode 10.2.
Pusher Beams overview
Beams is a service from Pusher to simplify sending push notifications to users. It allows you to send notifications to groups of users who have all subscribed to a certain topic, which Pusher calls Device Interests. You can also send private push notifications to individual users.
Let me describe what we will be doing today.
Interests
The user client app (Android or iOS) tells Pusher that they are interested in some topic. Lets say it’s apples. Pusher makes note of it.
The app admin wants to tell everyone who is interested in apples that they are on sale. So the admin tells the Dart server. Dart doesn’t know who is interested in apples, but Pusher does, so Dart tells Pusher. Finally, Pusher sends a push notification to every user that is interested in apples.
Authenticated users
Sometimes a user (let’s call her Mary) may want to get personalized notifications. To do that she has to prove who she is by authenticating her username and password on the Dart server. In return, the Dart server gives her a token.
Mary gives the token to Pusher, and since the token proves she really is Mary, Pusher makes a note of it.
Now Mary ordered some apples a couple days ago, and the company admin wants to let Mary know that they will be arriving today. The admin tells the Dart server, the server tells Pusher, and Pusher tells Mary. Pusher doesn’t tell any other user, just Mary.
Implementation plan
So that is how device interests and user push notifications work. In order to implement this we have four tasks to do. We have to create an Android user app, an iOS user app, an admin app, and the Dart server. Feel free to make only one user app rather than both the Android and the iOS one, though.
Android user app
Create a new Android app. (I’m using Kotlin and calling my package name com.example.beamstutorial
.) Then set it up as described in the Pusher Beams documentation. Make sure that you can receive the basic “Hello world” notification. I won’t repeat those directions here, but there are a couple of things to note:
- If you notice a version conflict error with the
appcompat
support inbuild.gradle
, see this solution. - Your app should be minimized in order to receive push notifications.
Now you should have a Pusher Beams account, a Firebase account, and an Android app that receives push notifications.
Interest notifications
Let’s modify the app to tell Pusher that the user is interested in “apples” instead of “hello”.
In the MainActivity
, update the following line:
PushNotifications.addDeviceInterest("apples")
That’s all we need to do to tell Pusher that this user is interested in apples.
Authenticated user notifications
In order to receive personalized messages from Pusher we need to get a token from the Dart server (which we will be building soon).
We’re going to add an AsyncTask
to make the request. Update the MainActivity
to look like the following:
// app/src/main/java/com/example/beamstutorial/MainActivity.kt
package com.example.beamstutorial
import android.os.AsyncTask
import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import android.util.Base64
import android.util.Log
import com.pusher.pushnotifications.BeamsCallback
import com.pusher.pushnotifications.PushNotifications
import com.pusher.pushnotifications.PusherCallbackError
import com.pusher.pushnotifications.auth.AuthData
import com.pusher.pushnotifications.auth.AuthDataGetter
import com.pusher.pushnotifications.auth.BeamsTokenProvider
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// TODO replace this instance ID with your own
val instanceId = "your_instance_id_here"
PushNotifications.start(getApplicationContext(), instanceId)
// Interests
PushNotifications.addDeviceInterest("apples")
// Authenticated user
RegisterMeWithPusher().execute()
}
class RegisterMeWithPusher : AsyncTask<Void, Void, Void>() {
override fun doInBackground(vararg params: Void): Void? {
// hardcoding the username and password both here and on the server
val username = "Mary"
val password = "mypassword"
val text = "$username:$password"
val data = text.toByteArray()
val base64 = Base64.encodeToString(data, Base64.NO_WRAP)
// get the token from the Dart server
val serverUrl = "http://10.0.2.2:8888/token"
val tokenProvider = BeamsTokenProvider(
serverUrl,
object: AuthDataGetter {
override fun getAuthData(): AuthData {
return AuthData(
headers = hashMapOf(
"Authorization" to "Basic $base64"
)
)
}
}
)
// send the token to Pusher
PushNotifications.setUserId(
username,
tokenProvider,
object : BeamsCallback<Void, PusherCallbackError> {
override fun onFailure(error: PusherCallbackError) {
Log.e("BeamsAuth",
"Could not login to Beams: ${error.message}")
}
override fun onSuccess(vararg values: Void) {
Log.i("BeamsAuth", "Beams login success")
}
}
)
return null
}
}
}
Don’t forget to replace the instance ID with your own (from the Beams dashboard).
In the manifest add the INTERNET
permission:
<uses-permission android:name="android.permission.INTERNET" />
And since we are using a clear text HTTP connection in this tutorial, we need to also update the manifest to include the following:
<application
android:usesCleartextTraffic="true"
...
>
You should not include this in a production app. Instead, use an HTTPS connection (see this tutorial for more explanation).
That’s all we can do for now until the server is running. You can skip the iOS user app section if you like and go on to the admin app section.
iOS user app
Create a new iOS app. (I’m calling my package name com.example.beamstutorial
.) Then set it up as described in the Pusher Beams documentation. Make sure that you can receive the basic “Hello world” notification. I won’t repeat those directions here, but there are a couple of things to note:
- At the time of this writing, the Cocoa Pods installation had a deprecated API for the Beams
PushNotifications
. The Carthage installation is up-to-date, though. See this tutorial for help with Carthage. - You need to add “remote-notification” to the list of your supported
UIBackgroundModes
in yourInfo.plist
file. Rather than editing it directly, you can make the change in Targets > your app Capabilities > Background Modes (see this for help). - You can’t receive push notifications in the iOS simulator. You have to use a real device.
- Your app should be minimized in order to receive the push notifications.
Now you should have a Pusher Beams account, APNs configured, and an iOS app installed on a real device that receives push notifications.
Interest notifications
Let’s modify the app to tell Pusher that the user is interested in “apples” instead of “hello”.
In AppDelegate.swift
, update the following line:
try? self.pushNotifications.addDeviceInterest(interest: "apples")
That’s all we need to do to tell Pusher that this user is interested in apples.
Authenticated user notifications
In order to receive personalized messages from Pusher, we need to get a token from the Dart server (which we will be making soon).
Open ViewController.swift
and replace it with the following code:
// beamstutorial/ViewController.swift
import UIKit
import PushNotifications
class ViewController: UIViewController {
let beamsClient = PushNotifications.shared
// hardcoding the username and password both here and on the server
let userId = "Mary"
let password = "mypassword"
// TODO: As long as your iOS device and development machine are on the same wifi
// network, change the following IP to the wifi router IP address where your
// Dart server will be running.
let serverIP = "192.168.1.3"
override func viewDidLoad() {
super.viewDidLoad()
// get the token from the server
let serverUrl = "http://\(serverIP):8888/token"
let tokenProvider = BeamsTokenProvider(authURL: serverUrl) { () -> AuthData in
let headers = ["Authorization": self.authHeaderValueForMary()]
let queryParams: [String: String] = [:]
return AuthData(headers: headers, queryParams: queryParams)
}
// send the token to Pusher
self.beamsClient.setUserId(userId,
tokenProvider: tokenProvider,
completion:{ error in
guard error == nil else {
print(error.debugDescription)
return
}
print("Successfully authenticated with Pusher Beams")
})
}
func authHeaderValueForMary() -> String {
guard let data = "\(userId):\(password)".data(using: String.Encoding.utf8)
else { return "" }
let base64 = data.base64EncodedString()
return "Basic \(base64)"
}
}
Since we are using a clear text HTTP connection in this tutorial, we need to also update the Info.plist
file to include the following:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
You should not include this in a production app. Instead, use an HTTPS connection (see this tutorial for more explanation).
It’s extra tricky getting a real device talking to the Dart server that will be running on our development machine. We can’t just connect to localhost like we can when we use the iOS simulator. As I noted in the comments above, if your iPhone and development machine are sharing a wifi network, you can use the router IP address of your development machine. So if you haven’t already, update that in the code above. For help finding the IP address, check out the following links:
That’s all we can do for now until the server is running.
Admin app
We need a means to trigger the Dart server to talk to Pusher and send the push notifications. I’m going to make a simple iOS app running on the simulator, but you can do it another way if you like. You could user curl, Postman, or an Android app. If you choose one of those, this is the REST API you will need to set up.
// send push notification to interests
POST http://localhost:8888/admin/interests
// send push notification to user
POST http://localhost:8888/admin/users
The authorization header for both of these should be Basic with a username of admin
and the password as password123
. Base64 encoded, this would be:
Authorization: Basic YWRtaW46cGFzc3dvcmQxMjM=
iOS version of the admin app
In a new Xcode project, create a simple layout with two buttons:
Note: This admin app does not send the actual device interest or username in the body of the HTTP request. The reason for that was to keep this tutorial as short as possible by avoiding the need to serialize and deserialize JSON. The push notifications are hardcoded on the server. Simply sending a POST request to the proper route will make the server send the push notifications.
Replace ViewController.swift
with the following code:
// beams_admin_app/ViewController.swift
import UIKit
class ViewController: UIViewController {
// hardcoding the username and password both here and on the Dart server
let username = "admin"
let password = "password123"
// using localhost is ok since this app will be running on the simulator
let host = "http://localhost:8888"
// tell server to send a notification to device interests
@IBAction func onInterestsButtonTapped(_ sender: UIButton) {
// set up request
guard let url = URL(string: "\(host)/admin/interests") else {return}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(authHeaderValue(), forHTTPHeaderField: "Authorization")
// send request
let task = URLSession.shared.dataTask(with: request) {
(data, response, error) in
guard let statusCode = (response as? HTTPURLResponse)?.statusCode
else {return}
guard let body = data
else {return}
guard let responseString = String(data: body, encoding: .utf8)
else {return}
print("POST result: \(statusCode) \(responseString)")
}
task.resume()
}
// Returns the Auth header value for Basic authentication with the username
// and password encoded with Base64. In a real app these values would be obtained
// from user input.
func authHeaderValue() -> String {
guard let data = "\(username):\(password)".data(using: .utf8) else {
return ""
}
let base64 = data.base64EncodedString()
return "Basic \(base64)" // "Basic YWRtaW46cGFzc3dvcmQxMjM="
}
// tell server to send notification to authenticated user
@IBAction func onUserButtonTapped(_ sender: UIButton) {
// set up request
guard let url = URL(string: "\(host)/admin/users") else {return}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(authHeaderValue(), forHTTPHeaderField: "Authorization")
// send request
let task = URLSession.shared.dataTask(with: request) {
(data, response, error) in
guard let statusCode = (response as? HTTPURLResponse)?.statusCode
else {return}
guard let body = data
else {return}
guard let responseString = String(data: body, encoding: .utf8)
else {return}
print("POST result: \(statusCode) \(responseString)")
}
task.resume()
}
}
Remember to hook up the buttons to the IBAction
methods.
As before, since we are using a clear text HTTP connection in this tutorial, we need to also update the Info.plist
file to include the following:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
That’s it for now. Let’s make the Dart server.
Dart server
Create a new Aqueduct server project:
aqueduct create dart_server
Dependencies
At the time of this writing Pusher does not officially support Dart servers, so I created a Dart server SDK based on the API docs. It is available on Pub here. In your server’s pubspec.yaml
file, add the dependency:
dependencies:
pusher_beams_server: ^0.1.4
We’ll set up three routes in channel.dart
. Open that file and replace it with the following code.
// dart_server/lib/channel.dart
import 'package:dart_server/controllers/auth.dart';
import 'package:dart_server/controllers/token.dart';
import 'package:dart_server/controllers/interests.dart';
import 'package:dart_server/controllers/users.dart';
import 'dart_server.dart';
class DartServerChannel extends ApplicationChannel {
// These middleware validators will check the username
// and passwords before allowing them to go on.
BasicValidator normalUserValidator;
AdminValidator adminValidator;
Future prepare() async {
logger.onRecord.listen(
(rec) => print("$rec ${rec.error ?? ""} ${rec.stackTrace ?? ""}"));
normalUserValidator = BasicValidator();
adminValidator = AdminValidator();
}
Controller get entryPoint {
final router = Router();
// user app will get a Pusher auth token here
router
.route('/token')
.link(() => Authorizer.basic(normalUserValidator))
.link(() => TokenController());
// admin app will send push notifications for device interests here
router
.route('/admin/interests')
.link(() => Authorizer.basic(adminValidator))
.link(() => InterestsController());
// admin app will send push notifications to authenticated users here
router
.route('/admin/users')
.link(() => Authorizer.basic(adminValidator))
.link(() => UsersController());
return router;
}
}
Create a controllers
folder in lib
. Make a file name auth.dart
where we will put the username and password validation middleware. Paste in the following code:
// dart_server/lib/controllers/auth.dart
import 'dart:async';
import 'package:aqueduct/aqueduct.dart';
// Hardcoding username and passwords both here and in the client apps
// admin: password123
// Mary: mypassword
// hash generated with AuthUtility.generatePasswordHash()
// A production app would store these in a database.
final Map<String, User> adminUsers = {
'admin': User(
username: 'admin',
saltedPasswordHash: 'ntQLWWIu/nubfZhCEy9sXgwRijuBV+d9ZN2Id3hTLbs=',
salt: 'mysalt1'),
};
final Map<String, User> normalUsers = {
'Mary': User(
username: 'Mary',
saltedPasswordHash: 'JV0R5CH9mnA6rcOGnkzSvIeGkHUvtnnvUCuFBc3XD+4=',
salt: 'mysalt2'),
};
class User {
User({this.username, this.saltedPasswordHash, this.salt});
String username;
String saltedPasswordHash;
String salt;
}
class BasicValidator implements AuthValidator {
final _requireAdminPriveleges = false;
FutureOr<Authorization> validate<T>(
AuthorizationParser<T> parser, T authorizationData,
{List<AuthScope> requiredScope}) {
// Get the parsed username and password from the basic
// authentication header.
final credentials = authorizationData as AuthBasicCredentials;
// check if user exists
User user;
if (_requireAdminPriveleges) {
user = adminUsers[credentials.username];
} else {
user = normalUsers[credentials.username];
}
if (user == null) {
return null;
}
// check if password matches
final hash = AuthUtility.generatePasswordHash(credentials.password, user.salt);
if (user.saltedPasswordHash == hash) {
return Authorization(null, null, this, credentials: credentials);
}
// causes a 401 Unauthorized response
return null;
}
// This is for OpenAPI documentation. Ignoring for now.
List<APISecurityRequirement> documentRequirementsForAuthorizer(
APIDocumentContext context, Authorizer authorizer,
{List<AuthScope> scopes}) {
return null;
}
}
class AdminValidator extends BasicValidator {
bool get _requireAdminPriveleges => true;
}
Next make a file named tokens.dart
(in controllers
) to handle when the user app needs to get a Pusher Beams auth token so that it can receive personalized push notifications. Paste in the following code:
// dart_server/lib/controllers/token.dart
import 'dart:async';
import 'package:aqueduct/aqueduct.dart';
import 'package:pusher_beams_server/pusher_beams_server.dart';
import 'package:dart_server/config.dart';
class TokenController extends ResourceController {
PushNotifications beamsClient;
.get()
Future<Response> generateBeamsTokenForUser() async {
// get the username from the already authenticated credentials
final username = request.authorization.credentials.username;
// generate the token for the user
beamsClient ??= PushNotifications(Properties.instanceId, Properties.secretKey);
final token = beamsClient.generateToken(username);
// return the token to the user
return Response.ok({'token':token});
}
}
Also in the controllers
folder, create a file called interests.dart
. When requested by the admin app, it will tell Pusher to send notifications to users who have subscribed the apples
interest. Paste in the following code:
// dart_server/lib/controllers/interests.dart
import 'dart:async';
import 'dart:io';
import 'package:aqueduct/aqueduct.dart';
import 'package:pusher_beams_server/pusher_beams_server.dart';
import 'package:dart_server/config.dart';
class InterestsController extends ResourceController {
PushNotifications beamsClient;
// send push notifications to users who are subscribed to the interest
.post()
Future<Response> notifyInterestedUsers() async {
beamsClient ??= PushNotifications(Properties.instanceId, Properties.secretKey);
const title = 'Sale';
const message = 'Apples are 50% off today!';
final fcm = {
'notification': {
'title': title,
'body': message,
}
};
final apns = {
'aps': {
'alert': {
'title': title,
'body': message,
}
}
};
final response = await beamsClient.publishToInterests(
['apples'],
apns: apns,
fcm: fcm,
);
return Response.ok(response.body)..contentType = ContentType.text;
}
}
And again in the controllers
folder, create a file called users.dart
. When requested by the admin app, it will tell Pusher to send a personal notification the user Mary
. Paste in the following code:
// dart_server/lib/controllers/users.dart
import 'dart:async';
import 'dart:io';
import 'package:aqueduct/aqueduct.dart';
import 'package:pusher_beams_server/pusher_beams_server.dart';
import 'package:dart_server/config.dart';
class UsersController extends ResourceController {
PushNotifications beamsClient;
// send push notification to Mary
.post()
Future<Response> notifyAuthenticatedUsers() async {
beamsClient ??= PushNotifications(Properties.instanceId, Properties.secretKey);
const title = 'Purchase';
const message = 'Hello, Mary. Your purchase of apples will be delivered shortly.';
final apns = {
'aps': {
'alert': {
'title': title,
'body': message,
}
}
};
final fcm = {
'notification': {
'title': title,
'body':
message,
}
};
final response = await beamsClient.publishToUsers(
['Mary'],
apns: apns,
fcm: fcm,
);
return Response.ok(response.body)..contentType = ContentType.text;
}
}
In order not to expose the secret key in GitHub, let’s put it in a configuration file and add that file to .gitignore
. Create a file called config.dart
in the lib
folder.
// dart_server/lib/config.dart
// Include this file in .gitignore
class Properties {
// exchange these values with valid ones from your Beams dashboard
static const instanceId = 'your_instance_id_here';
static const secretKey = 'your_secret_key_here';
}
Don’t forget to add the filename to .gitignore
and also to replace the instanceId
and secretKey
with your own (from the Beams dashboard).
Save all of your changes. We’re finally ready to test everything out.
Testing
Set everything up as follows:
Aqueduct Dart server
Start the Aqueduct server in the terminal with the following command:
aqueduct serve
Admin app
Start the admin app in the iOS simulator. (Or use Postman or curl
or your own Android implementation. See the admin app section above for notes about that.)
User apps
Depending on what you made, prepare the Android or iOS user app (or both):
- Install the Android user app on an Android emulator.
- Install the iOS user app on a real device connected to the same wifi network as your Aqueduct server. Remember to update the router IP of the server in your app.
Minimize the user app so that it is in the background.
Putting it all together
- Press the first button on the admin app to notify users interested in “apples”. You should see a notification pop up on the user app.
- Press the second button on the admin app to notify the user “Mary”. You should see a notification pop up on the user app.
Hopefully it worked for you. If it didn’t check the logs, and make sure you remembered to do the following tasks:
- You are using the correct instance ID in the iOS
AppDelegate.swift
, the AndroidMainActivity.kt
, and the Dartconfig.dart
. - You hooked up the buttons to the code in the iOS admin app.
- You iOS user app is on the same wifi network as your Dart server and has the correct router IP for the Dart server.
Conclusion
Today we learned how to send push notifications from a Dart server to Android and iOS.
In our contrived example, we sent a message to a single user who was interested in “apples”. If there had been a hundred or even a thousand users who were all interested in apples, the message would have gone out to all of them. Users can subscribe to different interests and you can use this to target your notification messages to appropriate groups of users.
We also sent a personalized message to an authenticated user. This ability allows us to send private updates to users for things that only apply to them.
You’ve got the technical know-how now. Now use your imagination to build something amazing! (Or at least don’t make something annoying . . . like an app that spams its users with push notifications 20 times a day. I borrowed my mom’s iPhone to make this tutorial and she was wondering why she keeps getting notifications about apples. And who’s Mary anyway? Oops.)
The source code for the projects in this tutorial are available on GitHub. Also check out the Pusher Beams Dart server SDK documentation.
10 June 2019
by Suragch