Home | Send Feedback

Sending push messages from Spring Boot to Ionic 4 over FCM

Published: February 26, 2017  •  Updated: December 06, 2018  •  ionic4, spring, javascript, java

In this blog post we implement a Spring Boot application that sends push messages and an Ionic 4 / Cordova app that receives and displays these 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 an account with Firebase (it's free) if you are 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.

Next we add the Firebase Client SDK for Java to our project. This library allows us to easily send push messages to FCM from Java.

    <dependency>
      <groupId>com.google.firebase</groupId>
      <artifactId>firebase-admin</artifactId>
      <version>6.6.0</version>

pom.xml

To send messages to FCM an application needs a service account from Firebase. This is a JSON file that contains all the necessary information (client id, private key, ...) a client needs to connect to Firebase. Because I don't want to hardcode the location of this file into the code, I create a simple configuration class that allows me to externalize this information into the file src/main/resources/application.properties.

package ch.rasc.push;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@ConfigurationProperties(prefix = "fcm")
@Component
public class FcmSettings {
  private String serviceAccountFile;

  public String getServiceAccountFile() {
    return this.serviceAccountFile;
  }

  public void setServiceAccountFile(String serviceAccountFile) {
    this.serviceAccountFile = serviceAccountFile;
  }

}

FcmSettings.java

Firebase

Before we continue with coding we need to set up a Firebase project. Login to your Firebase Console and create a new project or use an existing one. On the project page open the settings page by clicking on the cog wheel icon and click on Project settings and then open the Service Accounts tab.

step1 step1 step1

On the Service Accounts tab click on "Generate New Private Key" and download the file with "Generate Key". step1
Keep this file safe and don't commit it into a public repository. Everybody that has access to this file also has full access to this Firebase project.

Java

Back in Java we add the path to the service account JSON file to src/main/resources/application.properties

fcm.service-account-file=D:/ws/blog/push/server/pushdemo-91541-firebase-adminsdk-sltyt-c626d5a701.json

application.properties

Next we create a service bean and inject FcmSettings. In the constructor the application reads the service account file and initializes the Firebase library.

  public FcmClient(FcmSettings settings) {
    Path p = Paths.get(settings.getServiceAccountFile());
    try (InputStream serviceAccount = Files.newInputStream(p)) {
      FirebaseOptions options = new FirebaseOptions.Builder()
          .setCredentials(GoogleCredentials.fromStream(serviceAccount)).build();

      FirebaseApp.initializeApp(options);
    }
    catch (IOException e) {
      Application.logger.error("init fcm", e);
    }
  }

FcmClient.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 --type=angular

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

First we need to create and download the Firebase configuration file for a client. The Cordova plugin, we install later, needs this configuration file 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. Push5

Click on "Add Firebase to your Android app" Push6

In this dialog you have to enter the package name of your client project. You find the package name of your app in the file config.xml in the root folder of the app. The package name is the widget id, copy and paste this string into the Android package name text field.
Push6a

Click on the Register App button and then on the Download google-services.json button. Push7

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

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

Next we need to install a Cordova plugin that connects to FCM. For this example I use the cordova-plugin-firebase.

ionic cordova plugin add cordova-plugin-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 way is message sending based on the publish / subscribe model. Clients subscribe to one or more topics and the sender publishes messages to these topics. Each subscriber of a topic will receive messages a publisher sends 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 scenario 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 FcmClient fcmClient;

  private int id = 0;

  public PushChuckJokeService(FcmClient 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) {
    Map<String, String> data = new HashMap<>();
    data.put("id", String.valueOf(++this.id));
    data.put("text", joke);

    // Send a message
    System.out.println("Sending chuck joke...");
    try {
      this.fcmClient.sendJoke(data);
    }
    catch (InterruptedException | ExecutionException e) {
      Application.logger.error("send joke", e);
    }
  }

}

PushChuckJokeService.java

Spring injects the FcmClient 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 with the sendJoke method from the FcmClient.

  public void sendJoke(Map<String, String> data)
      throws InterruptedException, ExecutionException {

    AndroidConfig androidConfig = AndroidConfig.builder()
        .setTtl(Duration.ofMinutes(2).toMillis()).setCollapseKey("chuck")
        .setPriority(Priority.HIGH)
        .setNotification(AndroidNotification.builder().setTag("chuck").build()).build();

    ApnsConfig apnsConfig = ApnsConfig.builder()
        .setAps(Aps.builder().setCategory("chuck").setThreadId("chuck").build()).build();

    Message message = Message.builder().putAllData(data).setTopic("chuck")
        .setApnsConfig(apnsConfig).setAndroidConfig(androidConfig)
        .setNotification(
            new Notification("Chuck Norris Joke", "A new Chuck Norris joke has arrived"))
        .build();

    String response = FirebaseMessaging.getInstance().sendAsync(message).get();
    System.out.println("Sent message: " + response);
  }

FcmClient.java

The Firebase library allows us to set different configuration options for Android (AndroidConfig) and iOS (ApnsConfig).
For Android, we set the time to live to 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 Notification 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 supported options on this page: https://firebase.google.com/docs/cloud-messaging/http-server-ref

When you configure the Notification in the Message instance, the library uses it as default for Android and iOS. Alternatively, you may specify the notification options in the AndroidConfig and ApnsConfig object and use different values for the two platforms.

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

The data object contains the payload of the message and is the object the receiver has access to. 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.

Next the application sends the message with the sendAsync method to FCM. This is an asynchronous method. In this example I block the method with a call to get and wait for the response.

Topic: Client

For receiving topic messages the client has to subscribe to the topic and add a listener to the notification open event. In this example we do that in the constructor of the HomePage. The application needs to wait until the platform ready event is emitted which indicates that all Cordova plugins are loaded. The method onNotificationOpen expects a success handler, that is called each time a push message arrives and an error handler that is called in case of a failure. The success handler calls the handleNotification method each time a message arrives.

  constructor(private readonly http: HttpClient,
              platform: Platform,
              private readonly changeDetectorRef: ChangeDetectorRef) {

    platform.ready().then(() => {
      window['FirebasePlugin'].getToken(token => this.token = token,
                                        error => console.error('Error getting token', error));

      window['FirebasePlugin'].onTokenRefresh(token => this.token = token,
                                        error => console.error('Error token refresh', error));

      window['FirebasePlugin'].onNotificationOpen(notification => this.handleNotification(notification),
                                        error => console.error('Error notification open', error));

    });

home.page.ts

The handleNotification method keeps the last 5 messages stored in the instance variable items.

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

    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();
    }

    this.changeDetectorRef.detectChanges();
  }

home.page.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 displays the items array with a simple ngFor loop.

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

home.page.html

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

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

home.page.html

In the method onChange the code calls subscribe resp. unsubscribe from the Firebase Cordova plugin. These two functions expects as parameter the name of the topic. The topic name has to match the name of the topic in the Java code.

  onChange() {
    localStorage.setItem('allowPush', JSON.stringify(this.allowPush));

    if (this.allowPush) {
      window['FirebasePlugin'].subscribe(this.TOPIC_NAME);
    } else {
      window['FirebasePlugin'].unsubscribe(this.TOPIC_NAME);
    }
  }

home.page.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 method.
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 method.

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 window['FirebasePlugin'].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 window['FirebasePlugin'].grantPermission();
  }

  window['FirebasePlugin'].hasPermission(data => {
    if (data.isEnabled) {
      window['FirebasePlugin'].subscribe(topic_name);
    }
  });

Direct messages: Server

When a sender wants to send messages to a specific device, he needs to know the FCM registration token of the client. The client receives that token as soon as he is registered to FCM. Then he 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")
  @ResponseStatus(HttpStatus.NO_CONTENT)
  public void register(@RequestParam("token") String token) {
    System.out.println("register: " + token);
    this.pushSender.addToken(token);
  }

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

}

PersonalMessageController.java

The PersonalMessageSender bean manages the registered tokens in a Set and sends a random message every 30 seconds to all registered clients.

@Service
public class PersonalMessageSender {

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

  private final FcmClient fcmClient;

  private int id = 0;

  public PersonalMessageSender(FcmClient 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() {
    for (String token : this.tokenRegistry) {
      System.out.println("Sending personal message to: " + token);
      Map<String, String> data = new HashMap<>();
      data.put("id", String.valueOf(++this.id));
      data.put("text", String.valueOf(Math.random() * 1000));

      try {
        this.fcmClient.sendPersonalMessage(token, data);
      }
      catch (InterruptedException | ExecutionException e) {
        Application.logger.error("send personal message", e);
      }
    }
  }

}

PersonalMessageSender.java

The sendPushMessages method loops over all registered client tokens, create a data object with an id and a random text and calls the sendPersonalMessage from the FcmClient.

  public void sendPersonalMessage(String clientToken, Map<String, String> data)
      throws InterruptedException, ExecutionException {
    AndroidConfig androidConfig = AndroidConfig.builder()
        .setTtl(Duration.ofMinutes(2).toMillis()).setCollapseKey("personal")
        .setPriority(Priority.HIGH)
        .setNotification(AndroidNotification.builder().setTag("personal").build())
        .build();

    ApnsConfig apnsConfig = ApnsConfig.builder()
        .setAps(Aps.builder().setCategory("personal").setThreadId("personal").build())
        .build();

    Message message = Message.builder().putAllData(data).setToken(clientToken)
        .setApnsConfig(apnsConfig).setAndroidConfig(androidConfig)
        .setNotification(new Notification("Personal Message", "A Personal Message"))
        .build();

    String response = FirebaseMessaging.getInstance().sendAsync(message).get();
    System.out.println("Sent message: " + response);
  }

FcmClient.java

The sendPersonalMessage method works very similar like the sendJoke method, that sends messages to a topic. Instead of specifying a topic this method configures the receiver with setToken(clientToken). This way only the client with this token will receive this message.

Direct messages: Client

To get access to the FCM registration token the client has to call getToken. The code also registers a token refresh event listener, because Firebase may send a new token while the application is running.

Similar to the onNotificationOpen code this code runs in the platform ready event handler to make sure that Cordova is properly initialized, before calling any plugin methods.

  constructor(private readonly http: HttpClient,
              platform: Platform,
              private readonly changeDetectorRef: ChangeDetectorRef) {

    platform.ready().then(() => {
      window['FirebasePlugin'].getToken(token => this.token = token,
                                        error => console.error('Error getting token', error));

      window['FirebasePlugin'].onTokenRefresh(token => this.token = token,
                                        error => console.error('Error token refresh', error));

      window['FirebasePlugin'].onNotificationOpen(notification => this.handleNotification(notification),
                                        error => console.error('Error notification open', error));

    });

home.page.ts

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

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

home.page.html

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

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

home.page.ts

  register() {
    const formData = new FormData();
    formData.append('token', this.token);
    this.http.post(`${environment.serverURL}/register`, formData)
      .pipe(timeout(10000))
      .subscribe(() => localStorage.setItem('allowPersonal', JSON.stringify(this.allowPersonal)),
        error => this.allowPersonal = !this.allowPersonal);
  }

  unregister() {
    const formData = new FormData();
    formData.append('token', this.token);
    this.http.post(`${environment.serverURL}/unregister`, formData)
      .pipe(timeout(10000))
      .subscribe(() => localStorage.setItem('allowPersonal', JSON.stringify(this.allowPersonal)),
        error => this.allowPersonal = !this.allowPersonal);
  }

home.page.ts

Messages that are sent directly to the client are handled the same way as topic messages. The Cordova plugin emits the notification open event which then calls the handleNotification method we registered as the handler.

handleNotification is able to handle both message types, topic and direct messages, because the payload of both message types 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.