Home | Send Feedback

Push Notifications with Capacitor and Java

Published: April 03, 2020  •  ionic, spring, javascript, capacitor, java

In this blog post, we are going to implement a Spring Boot application that sends push notifications and an Ionic/Angular app, wrapped in Capacitor, that receives and displays these notifications. As the message service, we're going to use Firebase Cloud Messaging (FCM). 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 do not already have one. Go to https://firebase.google.com/ to sign up.

Server Setup

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

Next, we add the Firebase Client SDK to our project.

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

pom.xml

To send notifications with 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.

@ConfigurationProperties(prefix = "fcm")
@Component
public class FcmSettings {
  private String 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 cogwheel icon and click on Project settings and then open the Service Accounts tab.

Step1

Step2


On the Service Accounts tab, click on Generate new private key and download the file with a click on Generate key.

Step3


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 the Firebase project.

Server Setup

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

fcm.service-account-file=D:/ws/blog/push/server/visiondemo-1319-firebase-adminsdk-c50xg-dd7ba361a2.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.

@Service
public class FcmClient {

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

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

FcmClient.java

Client Setup

Before we start with the client, make sure that your development environment is set up correctly for building Capacitor apps. Follow the tutorial Capacitor Required Dependencies and install all the necessary tools.


The client is an Ionic application we create with the Ionic CLI. We don't need any additional pages or providers. We're adding all the necessary code to the HomePage that the start command creates. When the start command asks if you want to enable Capacitor support press y.

ionic start push blank
cd push
mkdir www && touch www/index.html
npx cap add android
npx cap add ios

Capacitor provides a built-in push notification plugin, this plugin returns FCM tokens on Android, and APNs tokens on iOS. If we want to work with FCM, we need on both platforms a FCM token. For that reason, we install an additional plugin: capacitor-fcm. This 3rd party plugin gives us the FCM tokens we need, and it also supports topic subscriptions.

npm install capacitor-fcm
npx cap sync ios
npx cap sync android

Make sure to always call sync after installing a new Capacitor plugin.


For Android, we need to do one extra step. Open the MainActivity.java file. You find it in the <project>/android/app/src/main/java folder. Add import io.stewan.capacitor.fcm.FCMPlugin; and inside the init callback add(FCMPlugin.class);

import io.stewan.capacitor.fcm.FCMPlugin;

import java.util.ArrayList;

public class MainActivity extends BridgeActivity {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    // Initializes the Bridge
    this.init(savedInstanceState, new ArrayList<Class<? extends Plugin>>() {{
      // Additional plugins you've installed go here
      // Ex: add(TotallyAwesomePlugin.class);
      add(FCMPlugin.class);
    }});
  }
}

MainActivity.java


Next, we need to download the Firebase configuration files for both platforms. The push plugin needs this information to connect to Firebase. Each platform has its own configuration file (Android: google-services.json, iOS: GoogleService-Info.plist).


Android

Open the Firebase Console and click on the menu Cloud Messaging.

Push5

You should see the following screen.

Push6

Click on the Android icon.

In the following dialog, you have to enter the package name of your client project. You find the package name in the file capacitor.config.json. The package name is the appId, copy and paste this string into the Android package name text field.

Push6

Click on Register app and then on Download google-services.json. You can skip the other steps.

Push7

Copy this file into the <project>/android/app folder

Push10


iOS

Go to Project Overview and click on the + Add app button. Add a new iOS app.

Push9

Enter the appId from capacitor.config.json into the iOS bundle ID field, and download the GoogleService-Info.plist file.

Push8

Add this file to the App folder in Xcode.

Push11

Add the Push Notifications capability to your project.

Push12


Firebase -> APNs

The next step is to configure Firebase. The issue is that only APNs (Apple Push Notifications service) can send push notifications to iOS devices, and our server sends all push notifications to FCM. Therefore, we need to permit Firebase to relay push notifications for iOS devices to APNs.

Go to your Apple developer account. Open the menu Certificates, IDs & Profiles and there open the menu Keys

Push13

Register a new key. Enter an arbitrary name and select Apple Push Notifications service (APNs). Click Continue, Register and finally Download. The browser downloads a .p8 file.

Push14

Open the Firebase Console, open Project Overview and then select the tab Cloud Messaging

Push15

In the iOS app configuration, you have to upload the .p8 file. Click on Upload.

Push16

Enter Key ID and Team ID. The Key ID is part of the .p8 file name.

Push17

You find the Team ID in your Apple Developer Console in the menu Membership

Push18

Send options

FCM supports two ways of sending push notifications to clients.

One way is based on the publish/subscribe model. Clients subscribe to one or more topics, and the sender publishes notifications to these topics. Each subscriber of a topic receives the notification. FCM automatically creates topics when a client subscribes, keeps track of all the subscribed clients, and routes incoming notifications from a publisher to the client.

The other option is sending notifications directly to one specific device. The sender needs to know the FCM registration token of the receiver when he wants to address a specific client. In the following example, we are going to see how to access this token on the client, and how to send it to the publisher (server).

Topic: Server

Sending push notifications to a topic is very convenient 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(Notification.builder().setTitle("Chuck Norris Joke")
            .setBody("A new Chuck Norris joke has arrived").build())
        .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 period, it is not going to receive the message. FCM supports a maximum time to live of 4 weeks, which is the default value when this option is not specified.

The Notification object configures what the notification dialog displays 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 object 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.

Lastly, the application sends the message with the sendAsync method to FCM.

Topic: Client

Setup

Import the push notifications libraries into your TypeScript class.

import {LocalNotificationActionPerformed, Plugins, PushNotificationActionPerformed} from '@capacitor/core';
import {FCM} from 'capacitor-fcm';

const fcm = new FCM();

home.page.ts


Subscribe / Unsubscribe

For receiving topic notifications, the client has to subscribe to the topic. capacitor-fcm provides the method subscribeTo for this purpose. It expects an object with one property topic as the argument.

      fcm.subscribeTo({topic: this.TOPIC_NAME});

home.page.ts

To stop receiving notifications, you have to unsubscribe from the topic with the method unsubscribeFrom. This method expects the same argument as subscribeTo

      fcm.unsubscribeFrom({topic: this.TOPIC_NAME});

home.page.ts


Listen

To handle the incoming push notifications, we have to register a listener for the event pushNotificationReceived.

    Plugins.PushNotifications.addListener('pushNotificationReceived',
      notification => {
        this.handleNotification(notification.data);

home.page.ts

This event is only emitted when the application runs in the foreground, and it receives a notification object. The property data of this object contains the payload sent from the server. The notification object also contains other information like the title and body.

When the app is in the background (or not started yet), the system displays a notification dialog. When the user taps on this dialog, the system brings the app into the foreground (or starts it) and then emits a pushNotificationActionPerformed event.

    Plugins.PushNotifications.addListener('pushNotificationActionPerformed',
      (event: PushNotificationActionPerformed) => {
        this.handleNotification(event.notification.data);
      }
    );

home.page.ts

The notification property of the event object contains the same data as the pushNotificationReceived event.


Display notification

When the app runs in the foreground, the system does not display a notification dialog. If required, you can show a dialog with the Capacitor Local Notifications API.

The following example uses the title and body properties it gets from the push notification as the title and body for the local notification. It also passes the data property to the extra option. This allows us to access the server payload from the event handler. schedule immediately displays the notification dialog.

    Plugins.PushNotifications.addListener('pushNotificationReceived',
      notification => {
        this.handleNotification(notification.data);

        Plugins.LocalNotifications.schedule({
          notifications: [{
            title: notification.title ?? '',
            body: notification.body ?? '',
            id: Date.now(),
            extra: notification.data,
            smallIcon: 'res://ic_stat_name'
          }]
        });
      }
    );

home.page.ts

If you want to get notified when the user opens the dialog and taps on it, you have to register a localNotificationActionPerformed event handler. In the handler, we have access to the extra property, and to the payload of the push notification.

    Plugins.LocalNotifications.addListener('localNotificationActionPerformed',
      (event: LocalNotificationActionPerformed) => {
        this.handleNotification(event.notification.extra);
      });

home.page.ts


Display

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

  handleNotification(data: { text: string, id: number}): void {
    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 template displays the items array with a simple ngFor loop.

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

home.page.html

To demonstrate subscribing and unsubscribing 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 (ionChange)="onChange()" [(ngModel)]="allowPush"></ion-checkbox>
      </ion-col>
    </ion-row>

home.page.html


Push Permission

The permission system works differently on iOS and Android. On Android, the app only needs the internet permission to receive push notifications. On iOS, the app needs the push permission, and the user has to grant permission to the app. The Capacitor Push Notification API handles this automatically. We only have to connect to the notification server with register(), and the system shows a dialog on iOS that asks for permission. On Android, this method just connects to the push server and does not display anything.

    await Plugins.PushNotifications.register();

home.page.ts

Direct notifications: Server

When a sender wants to send notifications to a specific device, he needs to know the FCM registration token of the client. We can access this token with the help of the capacitor-fcm plugin. The client then needs to send that token to the server. For this purpose, we create a RestController that registers a handler for the URL /register and another handler for /unregister. In the client app, we're adding a checkbox where the user can enable and disable direct notifications. When the user enables it, the client fetches the FCM token and sends it 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, creates 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(Notification.builder().setTitle("Personal Message")
            .setBody("A Personal Message").build())
        .build();

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

FcmClient.java

The sendPersonalMessage method is almost identical to the sendJoke method, which sends notifications to a topic. The difference is that this method addresses a specific client with setToken(clientToken).

Direct notifications: Client

To get the FCM registration token, the client calls the asynchronous fcm.getToken() method of the capacitor-fcm plugin.

    await Plugins.PushNotifications.register();
    const {token} = await fcm.getToken();

home.page.ts

Also make sure that your client is registered to Firebase with the Plugins.PushNotifications.register(); method.


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

    <ion-row>
      <ion-col size="8">Send me Personal messages</ion-col>
      <ion-col size="4">
        <ion-checkbox (ionChange)="onPmChange()" [(ngModel)]="allowPersonal"></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.

  async register(): Promise<void> {
    await Plugins.PushNotifications.register();
    const {token} = await fcm.getToken();
    const formData = new FormData();
    formData.append('token', token);
    this.http.post(`${environment.serverURL}/register`, formData)
      .pipe(timeout(10000))
      .subscribe(() => localStorage.setItem('allowPersonal', JSON.stringify(this.allowPersonal)),
        _ => this.allowPersonal = !this.allowPersonal);
  }

home.page.ts

  async unregister(): Promise<void> {
    await Plugins.PushNotifications.register();
    const {token} = await fcm.getToken();
    const formData = new FormData();
    formData.append('token', token);
    this.http.post(`${environment.serverURL}/unregister`, formData)
      .pipe(timeout(10000))
      .subscribe(() => localStorage.setItem('allowPersonal', JSON.stringify(this.allowPersonal)),
        _ => this.allowPersonal = !this.allowPersonal);
  }

home.page.ts

In this demo application, notifications that are sent directly to clients are going to be handled the same way as topic notifications. In the pushNotificationReceived handler when the app runs in the foreground, and in the pushNotificationActionPerformed event handler when the app runs in the background or is not started yet. Note that the pushNotificationActionPerformed event is only emitted when the user taps on the notification dialog. If he dismisses the dialog, the app will never be notified.

In our demo application, the handleNotification method can handle both message types (topic and direct notifications) because the payload is the same.


You've reached the end of this tutorial about sending push notifications from Java/Spring Boot to a web app (Ionic/Angular) wrapped in Capacitor. I hope this information is useful to you.

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