Send messages from Spring Boot to Ionic 3 over FCM

Published: February 26, 2017  •  Updated: December 18, 2017  •  ionic, spring, google

In this blog post we implement a Spring Boot application that sends messages and a Ionic / Cordova app that receives and displays the messages. As the message service we will use Firebase Cloud Messaging (FCM) the successor of Google Cloud Messaging (GCM). Therefore if you want to follow this blog post and test the application you need to create a free account with Firebase if you not already have one. Go to https://firebase.google.com/ to sign up.

According to the Firebase pricing page sending push messages is free of charge and it looks like there is no limit on the number of messages an application is allowed to send (at least I haven't found any information about that)


Server Setup

The server is a Spring Boot application created with the https://start.spring.io/ website. Web is the sole dependency we need for this application.

The Firebase Client SDK for Java does not support FCM in the current release (4.1.2). That's not a problem because third party libraries already exists that implement the FCM api. For this example we use FcmJava. To include the library into our application we add these two dependencies to the pom.xml.

<dependency>
  <groupId>de.bytefish.fcmjava</groupId>
  <artifactId>fcmjava-core</artifactId>
  <version>2.5</version>
</dependency>

<dependency>
  <groupId>de.bytefish.fcmjava</groupId>
  <artifactId>fcmjava-client</artifactId>
  <version>2.5</version>
</dependency>

pom.xml

To send messages an application needs an API key from Firebase. Because I don't want to hardcode this key into the code I create a simple configuration class that allows me to externalize this information into the file src/main/resources/application.properties.

@ConfigurationProperties(prefix = "fcm")
@Component
public class FcmSettings implements IFcmClientSettings {
  private String apiKey;
  private String url;

  @Override
  public String getApiKey() {
    return this.apiKey;
  }

  //get / set methods
}

src/main/java/ch/rasc/push/FcmSettings.java

To get the API key we need to open the Firebase Console. Next open an existing project or create a new project. To display the key for the selected project click on the cogwheel icon (1), Project settings (2), then on the tab Cloud Messaging and there you find the Server key.

Push1

Copy and paste this key into the application.properties file. The content of the file should look like this

fcm.url=https://fcm.googleapis.com/fcm/send
fcm.api-key=AAAAr6zzxQo:APA91bG0JdCbjx6nD63N.......

src/main/resources/application.properties

In the Java code we create a Spring bean of type IFcmClient that is responsible for sending messages to FCM. We can then inject this class into other parts of our application.

@Bean
public IFcmClient fcmClient(FcmSettings settings) {
  return new FcmClient(settings);
}

src/main/java/ch/rasc/push/Application.java


Client Setup

The client is an Ionic application we create with the Ionic CLI. We don't need any additional pages or providers. We will add all the necessary code to the HomePage that the start command created.

ionic start push blank

To test the application we have to run it on a real device or in the emulator. I will run the app on an Android device, but FCM is not limited to Android it supports iOS devices as well.

cordova platform add android

Next we need to create and download the Firebase configuration files. The Cordova plugin we install later needs these configuration files for connecting to FCM. Each platform has their own configuration file (Android -> google-services.json, iOS -> GoogleService-Info.plist).
Go to the Firebase Console and open the project. A click on the Overview menu displays the following buttons. Push2

Click on the Android button to open this dialog Push3a

In this dialog you have to enter the Package name. You find the package name of your app in the file config.xml in the app root folder. The package name is the widget id, copy and paste that into the dialog. Push3b

Click on the Add App button and then on the Download google-services.json button. Push4

The browser downloads the file to your computer. Copy the file to the root folder of your Ionic project.

If you need to support iOS add an iOS app to the Firebase project, download the GoogleService-Info.plist file and copy it into the root folder of your app.

Next we need to install a Cordova plugin that connects to FCM. I choose the cordova-plugin-firebase because Ionic Native supports this plugin and wraps the functions with Promise and Observable and makes it very easy to use it in an Ionic app.

cordova plugin add cordova-plugin-firebase
npm install @ionic-native/firebase

This plugin does not only support FCM, it also supports Firebase Analytics and Remote Config.


Send options

FCM supports two ways of sending messages to clients. One option is message sending based on the publish / subscribe model. Clients subscribe to one or more topics and the sender publishes messages to these topics. Every subscriber of a topic will receive all messages a publisher sent to this topic. FCM automatically creates topics when a client subscribes to it, keeps track of all the subscribed clients and routes incoming messages from a sender to the right device.

The other option is sending messages directly to one specific device. For this the sender needs to know the FCM registration token of the receiver. The Cordova plugin provides functionality to access this token.

We will create an example for each of these options.


Topic: Server

Sending messages to a topic is very easy because neither the server nor the client needs to know the registration token. As an example we create a Spring service with a scheduled method that runs every 30 seconds, fetches a joke from the Chuck Norris Database and sends it to the topic "chuck".

@Service
public class PushChuckJokeService {

  private final RestTemplate restTemplate;

  private final IFcmClient fcmClient;

  private int id = 0;

  public PushChuckJokeService(IFcmClient fcmClient) {
    this.restTemplate = new RestTemplate();
    this.fcmClient = fcmClient;
  }

  @Scheduled(fixedDelay = 30_000)
  public void sendChuckQuotes() {
    IcndbJoke joke = this.restTemplate.getForObject("http://api.icndb.com/jokes/random",
        IcndbJoke.class);
    sendPushMessage(HtmlEscape.unescapeHtml(joke.getValue().getJoke()));
  }

  void sendPushMessage(String joke) {
    FcmMessageOptions options = FcmMessageOptions.builder()
        .setTimeToLive(Duration.ofMinutes(2)).build();

    NotificationPayload payload = NotificationPayload.builder()
        .setBody("A new Chuck Norris joke has arrived").setTitle("Chuck Norris Joke")
        .setTag("chuck").build();

    Map<String, Object> data = new HashMap<>();
    data.put("id", ++this.id);
    data.put("text", joke);

    // Send a message
    System.out.println("Sending chuck joke...");

    Topic topic = new Topic("chuck");
    TopicUnicastMessage message = new TopicUnicastMessage(options, topic, data, payload);

    TopicMessageResponse response = this.fcmClient.send(message);
    ErrorCodeEnum errorCode = response.getErrorCode();
    if (errorCode != null) {
      System.out.println("Topic message sending failed: " + errorCode);
    }
  }
}

src/main/java/ch/rasc/push/PushChuckJokeService.java

Spring injects the IFcmClient bean, we created earlier, into this class. The scheduled method sendChuckQuotes first fetches a joke then calls the private method sendPushMessage that composes and sends the message to FCM.
First it specifies a time to live of 2 minutes. When a device is offline for longer than this specified time period it will not receive the message. FCM supports a maximum time to live of 4 weeks, which is the default value when the application does not specify anything. The NotificationPayload object configures what the notification dialog should display when the client receives the message while the app is in the background. You find a detailed description of all the options on this page: https://firebase.google.com/docs/cloud-messaging/http-server-ref

Note: The client does not display a notification dialog when the app is in the foreground.

The data object is the payload of the message and is the object the receiver will get. The receiver does not receive or has access to the message option and notification payload objects. The data object does not have to be a Map it may be any Pojo that can be serialized into JSON. The method then creates a Topic and a TopicUnicastMessage and sends the message with the send method from the IFcmClient instance. Additionally the Java library provides a TopicMulticastMessage class that allows an application to send a message to multiple topics in one call.


Topic: Client

For receiving topic messages the client has to subscribe to the topic and add a listener to the notification open event. It does that in the constructor of the HomePage (home.ts). The application needs to wait until the platform ready event is emitted which indicates that all Cordova plugins are loaded. Then the app subscribes to the notification open observable, which calls the handleNotification function every time a message arrives at the client.

  constructor(private readonly http: HttpClient,
              platform: Platform,
              private readonly ngZone: NgZone,
              private readonly firebase: Firebase,
              private readonly storage: Storage) {

      ...

      this.firebase.onNotificationOpen()
                   .subscribe(notification => this.handleNotification(notification));
    });

  }

src/pages/home/home.ts

The handleNotification function keeps the last 5 messages in the instance variable items. This application needs to update the items variable inside an Angular zone. Otherwise the display would not refresh and show the new data.

handleNotification(data) {
 if (!data.text) {
   return;
 }

 this.ngZone.run(() => {
   this.items.splice(0, 0, {id: data.id, text: data.text});

   //only keep the last 5 entries
   if (this.items.length > 5) {
     this.items.pop();
   }

 });
}

src/pages/home/home.ts

The argument that the handleNotification functions receives corresponds to the data object from the server. In our example this is an object with the two keys id and text.

The template outputs the items array with a ngFor loop.

<ion-list>
 <ion-item *ngFor="let item of items" text-wrap>
   {{item.id}}. {{item.text}}
 </ion-item>
</ion-list>

src/pages/home/home.html

To demonstrate subscribing and unsubscribing to a topic we add a checkbox to the template

<ion-row>
  <ion-col col-8>Send me Chuck Norris Jokes</ion-col>
  <ion-col col-4>
    <ion-checkbox checked="false" [(ngModel)]="allowPush" (click)="onChange()"></ion-checkbox>
  </ion-col>
</ion-row>

src/pages/home/home.html

In the method onChange the code calls this.firebase.subscribe resp. this.firebase.unsubscribe when the checkbox is checked or not. These two functions expects the name of the topic as a parameter. The topic name has to match the name of the topic in the Java code.

private readonly TOPIC_NAME = "chuck";

onChange() {
  this.storage.set("allowPush", this.allowPush);

  if (this.allowPush) {
    this.firebase.subscribe(this.TOPIC_NAME);
  }
  else {
    this.firebase.unsubscribe(this.TOPIC_NAME);
  }
}

src/pages/home/home.ts

Note: The notification flow is different when the app is in foreground or background.
Foreground: The application receives the message directly in the code without displaying a notification dialog and emits the notification open event which then calls the handleNotification function.
Background: Device presents a notification dialog to the user. The user taps on the notification, the app opens and emits the notification open event and then calls the handleNotification function.

iOS: The permission system works differently on iOS than on Android. On Android the app only needs the internet permission to receive push messages. On iOS the app needs the push permission and the user has to grant permission to the app. You can implement that in your code with a call to the firebase.grantPermission method. This method only works on iOS so make sure that you check the platform first. A call to this method presents a dialog to the user where he can allow push messages.

  if (this.platform.is('ios')) {
    await this.firebase.grantPermission();
  }
  if (await this.firebase.hasPermission()) {
    this.firebase.subscribe(topic);
  }

Direct messages: Server

When a sender wants to send messages to a specific device he needs to know the FCM registration token of this client. The client receives that token as soon as it is registered to FCM and needs to send that token to the server. For that reason we create a RestController that registers a handler for the url /register and another handler for /unregister. In the client app we will add a checkbox where the user can enable and disable direct messages. When the user enables it the client sends the token to the /register endpoint.

@RestController
@CrossOrigin
public class PersonalMessageController {

  private final PersonalMessageSender pushSender;

  public PersonalMessageController(PersonalMessageSender pushSender) {
    this.pushSender = pushSender;
  }

  @PostMapping("/register")
  public void register(@RequestParam("token") String token) {
    System.out.println("register: " + token);
    this.pushSender.addToken(token);
  }

  @PostMapping("/unregister")
  public void unregister(@RequestParam("token") String token) {
    System.out.println("unregister: " + token);
    this.pushSender.removeToken(token);
  }
}

src/main/java/ch/rasc/push/PersonalMessageController.java

The PersonalMessageSender bean manages the registered tokens in a Set and sends messages every 30 seconds to them.

@Service
public class PersonalMessageSender {

  private final Set<String> tokenRegistry = new CopyOnWriteArraySet<>();

  private final IFcmClient fcmClient;

  private int id = 0;

  public PersonalMessageSender(IFcmClient fcmClient) {
    this.fcmClient = fcmClient;
  }

  public void addToken(String token) {
    this.tokenRegistry.add(token);
  }

  public void removeToken(String token) {
    this.tokenRegistry.remove(token);
  }

  @Scheduled(fixedDelay = 30_000)
  void sendPushMessages() {
    FcmMessageOptions options = FcmMessageOptions.builder()
        .setTimeToLive(Duration.ofMinutes(2)).build();

    NotificationPayload payload = NotificationPayload.builder()
        .setBody("A Personal Message").setTitle("Personal Message").setTag("personal")
        .build();

    for (String token : this.tokenRegistry) {
      System.out.println("Sending personal message to: " + token);
      Map<String, Object> data = new HashMap<>();
      data.put("id", ++this.id);
      data.put("text", Math.random() * 1000);

      DataUnicastMessage message = new DataUnicastMessage(options, token, data, payload);
      FcmMessageResponse response = this.fcmClient.send(message);
      for (FcmMessageResultItem result : response.getResults()) {
        if (result.getErrorCode() != null) {
          System.out.printf("Sending to %s failed. Error Code %s\n", token,
              result.getErrorCode());
        }
      }
    }
  }

}

src/main/java/ch/rasc/push/PersonalMessageSender.java

Similar to the topic sender this service creates a message option and a notification payload object. Then it loops over the tokenRegistry set with all the registered tokens and sends each a message with the data object as the payload. This object will be converted to a JSON with the keys id and text. The difference to the topic sender is that the application instantiates the class DataUnicastMessage and specifies the token of the receiver as the second parameter.


Direct messages: Client

To get access to the FCM registration token the client can listen to the token refresh event. Similar to the onNotificationOpen() code we add this code to the platform ready event handler to make sure that Cordova is properly initialized.

platform.ready().then(() => {
   this.firebase.onTokenRefresh()
       .subscribe((token: string) => this.token = token);
});

src/pages/home/home.ts

In the template we add a checkbox where the user can enable and disable direct messages.

<ion-row>
 <ion-col col-8>Send me Personal messages</ion-col>
 <ion-col col-4>
   <ion-checkbox checked="false" [(ngModel)]="allowPersonal" (click)="onPmChange()"></ion-checkbox>
 </ion-col>
</ion-row>

src/pages/home/home.html

In the click handler of the checkbox (onPmChange) the program either post a request to the /register or /unregister endpoint with the FCM registration token in the body.

  register() {
    const formData = new FormData();
    formData.append('token', this.token);
    this.http.post(`http://192.168.178.84:8080/register`, formData)
      .pipe(timeout(10000))
      .subscribe(() => this.storage.set("allowPersonal", this.allowPersonal),
        error => this.allowPersonal = !this.allowPersonal);
  }

  unregister() {
    const formData = new FormData();
    formData.append('token', this.token);
    this.http.post(`http://192.168.178.84:8080/unregister`, formData)
      .pipe(timeout(10000))
      .subscribe(() => this.storage.set("allowPersonal", this.allowPersonal),
        error => this.allowPersonal = !this.allowPersonal);
  }

  onPmChange() {
    if (this.allowPersonal) {
      this.register();
    }
    else {
      this.unregister();
    }
  }

src/pages/home/home.ts

Handling the incoming messages happens exactly the same way as with the topic messages. The Cordova plugin emits the notification open event and our app calls handleNotification.

this.firebase.onNotificationOpen().subscribe(notification => this.handleNotification(notification));

src/pages/home/home.ts

handleNotification is able to handle the topic and direct messages, because the payload of both messages is exactly the same, an object with id and text as the keys.

You find the complete source code for the Ionic app and the Spring Boot application on GitHub.