For most mobile applications, push notifications are sent after some business logic is performed. Whether it’s a purchase on Jumia Food, new email notification on Gmail or confirmation for an Uber ride, almost all push notifications originate from the backend server/platform for the app then they are sent to the devices running the apps according to different parameters.

The most common method of handling these push notifications for Android, iOS and web is using Firebase cloud messaging. Firebase is a platform that enables faster app development with offerings that support analytics, storage and for this article, cloud messaging. From their website, “Firebase Cloud Messaging (FCM) is a cross-platform messaging solution that lets you reliably send messages at no cost.” The way FCM works is by installing libraries on the client-side where notifications/messages will be received (android for this set of walkthroughs), and also on backend environments from where to build, target and send these messages from. Tokens 'glue' both the client and backend/FCM together.

The system we shall be building in this article, part 2 and part 3 will be a simple android application with a spring boot backend that allows the following:

  • Account creation and automatic login from the android app -  At this point, we get the FCM token and store it in a database in the spring application.
  • Topic subscription via the topic screen (weather) - Here, we will use the android app to register/create topics, register device tokens to this topic and send messages to them. Only devices registered within a topic will receive these ‘topical’ notifications.
  • Topic un-subscription on the android app - A user can unsubscribe from a particular topic, and they will no longer receive notifications for it.
  • Message sending - On the android app, a user can send a simple message to another user provided that the user exists and their token is registered. The message will be routed through spring boot to FCM to the device.
  • Token refreshing - When a user uninstalls then re-installs the app or clears the app data, there will be a new token generated. With spring, we will handle removing the old token and saving the new one. Both for the user and for the topics the user is subscribed to.
  • As a bonus, we will also work on notifications muting on the android app to show how notifications work on both pre-Android 8 and Android 8 and above.

For this article, we shall be focusing on the spring boot part of the system. You will learn the following:

  • How to set up a spring project with FCM
  • Model creation using Lombok
  • Firebase service creation
  • Rest controller creation to use the service

Setting up the spring project and FCM

Setting up spring

Go to Spring Initializr to generate the code for the spring project. Here is a screenshot of the dependencies I have selected. Please note for this project we are going to use the H2 database instead of a full-blown database. I am also using maven and java instead of gradle/kotlin. Feel free to tweak the group, artifact, name and description to fit your details.

spring initializr configuration
Spring initializr configuration

Generate and download the zip file and proceed to import it into your IDE of choice. Once maven has finished downloading the dependencies we can proceed.

Setting up FCM

For the spring project to communicate with FCM and subsequently the android project, we need to create a firebase project and do some setup there. Head over to the firebase console and click on add project. You should see this screen:

firebase console creating a new project

Choose a project name and click continue. On step 2 click next with the defaults selected and on step 3 create a new analytics project and select the region you are in. Leave the default settings for sharing data selected, read through all the terms and select them if you are okay. Finally click create project and wait for the process to complete. Once done, navigate to the top left panel of the screen to the project overview - project settings and to the service accounts tab:

fcm service accounts
fcm service accounts

Click on the "Generate new private key" button and make sure you download the .json file that is generated. Move this file to the inside of your spring project to under the resources folder. That is all we need from the firebase console. For more comfortable use of the file, rename it to a more straightforward name like firebase-service-account.json. Add this file to the .gitignore file as it is confidential and should not be committed to git. To be able to use the JSON file, we need the firebase library inside our project. Import the following into the pom file. This is the latest firebase admin library at the time of writing.

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

After maven has imported the library, we need to do the final step to connect the library and the JSON file. To do this, go to the main java file for your project and pass this in above the main method:

@Bean
    FirebaseMessaging firebaseMessaging() throws IOException {
        GoogleCredentials googleCredentials = GoogleCredentials
                .fromStream(new ClassPathResource("firebase-service-account.json").getInputStream());
        FirebaseOptions firebaseOptions = FirebaseOptions
                .builder()
                .setCredentials(googleCredentials)
                .build();
        FirebaseApp app = FirebaseApp.initializeApp(firebaseOptions);
        return FirebaseMessaging.getInstance(app);
    }

We are exposing a bean here to be available everywhere in the app with the help of dependency injection.

Model Creation

Let's now proceed to create the 2 data models and the relationships that they will have with each other. We will have a users table and a weathers table. The last table will hold the user id and their token. When a user unsubscribes from the topic, we delete them from the respective table. This adds a layer of configuration to easily manage topics on the spring app before moving to FCM.

The user model looks like this. We annotate it with @Data for lombok to handle @ToString, @EqualsAndHashCode, @Getter on all fields, and @Setter on all non-final fields, and @RequiredArgsConstructor. We also add the entity for JPA to create a DB table and we modify the table name.

@EqualsAndHashCode(callSuper = true)
@Data
@Entity
@Table(name = "users")
public class User extends BaseModel {
    private String userName;
    private String userToken;
    private boolean notifications = true;
}

The weather model:

@EqualsAndHashCode(callSuper = true)
@Data
@Entity
@Table(name = "weather")
public class Weather extends BaseModel {
    @OneToOne
    private User user;
}

We use a OneToOne relationship from the weather model to the user model to store each user once they subscribe to the weather topic.

Both models extend a BaseModel which contains common fields. Here it is:

@MappedSuperclass
@Data
public class BaseModel {
    @Id
    @GeneratedValue
    private long id;
    private boolean success;
    private String errorMessage;
}

The mapped superclass annotation is needed to ensure JPA does not create a separate table with it and so that it can understand the inheritance.

With that, we are done setting up the data models for the project. We have to modify the h2 settings to enable us to view the console via a browser. Add the following to the application.properties file to enable the console and set the h2 db in memory. A downside to setting it in memory is that data will be lost once the application is killed. If you need to change the db to one that persists even after application restarts, check out this link.

spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:testdb
h2-console
h2-console

Run the project and head over to http://localhost:8080/h2-console and login. You should see the tables created matching the names we chose in the model classes. If you are unable to log in, make sure the JDBC url matches whatever you have in the application.properties file.

Data transfer models

We also create several data transfer POJOs which help in formatting data to and from the api. Here they are:

@Data
public class MessageDto extends BaseModel {
    private String senderToken;
    private String recipientUserName;
    private String message;
}
@Data
public class NotificationDto {
    private String subject;
    private String content;
    private String token;
    private Map<String, String> data;

}
@Data
public class SubscriptionDto {
    private boolean subscribe;
    private String userName;
}

Data repositories

We will use 2 repositories for this project to handle the two models that we created initially.

The user repository with custom query methods.

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    User findByUserName(String userName);

    User findByUserToken(String token);
}

The weather repository also with a custom query method.

@Repository
public interface WeatherRepository extends JpaRepository<Weather, Long> {
    Weather findByUser(User user);
}

The FCM service

Create a service that will work with FCM and handle different scenarios.

@Service
public class FirebaseService {
    private final FirebaseMessaging firebaseMessaging;

    public FirebaseService(FirebaseMessaging firebaseMessaging) {
        this.firebaseMessaging = firebaseMessaging;
    }

    public String sendNotificationWithoutData(NotificationDto notification) throws FirebaseMessagingException {
        AndroidNotification androidNotification = AndroidNotification.builder()
                .setClickAction(".Notification")
                .setTitle(notification.getSubject())
                .setBody(notification.getContent())
                .build();

        AndroidConfig androidConfig = AndroidConfig.builder()
                .setNotification(androidNotification)
                .build();

        Message message = Message
                .builder()
                .setToken(notification.getToken())
                .setAndroidConfig(androidConfig)
                .build();

        return firebaseMessaging.send(message);
    }

    public String sendNotificationWithData(NotificationDto notification) throws FirebaseMessagingException {
        AndroidNotification androidNotification = AndroidNotification.builder()
                .setClickAction(".Messaging")
                .setTitle(notification.getSubject())
                .setBody(notification.getContent())
                .build();

        AndroidConfig androidConfig = AndroidConfig.builder()
                .setNotification(androidNotification)
                .build();

        Message message = Message
                .builder()
                .setToken(notification.getToken())
                .putAllData(notification.getData())
                .setAndroidConfig(androidConfig)
                .build();

        return firebaseMessaging.send(message);
    }

    public int subscribeToTopic(String topic, List<String> tokens) throws FirebaseMessagingException {
        TopicManagementResponse topicManagementResponse = firebaseMessaging.subscribeToTopic(tokens, topic);
        return topicManagementResponse.getSuccessCount();
    }

    public int unSubscribeToTopic(String topic, List<String> tokens) throws FirebaseMessagingException {
        TopicManagementResponse topicManagementResponse = firebaseMessaging.unsubscribeFromTopic(tokens, topic);
        return topicManagementResponse.getSuccessCount();
    }
}

This service uses the bean that we created earlier and injects it into the constructor. We also have 2 methods that we will use in our controllers to send notifications: sendNotificationWithoutData and sendNotificationWithData. In both these methods, we create an android notification object that is needed to send notifications. We also create an android configuration to pass into the final message that is sent to the service. This configuration stores the android notification object.

For iOS and Web, there are different builder methods we can work with.

The last two methods, subscribeToTopic  and unSubscribeToTopic deal with creating a topic and adding/removing a list of tokens from firebase on the cloud. In both methods, we get back a count successful token for the action. Please note that firebase imposes a limit of 1000 tokens for each subscription/un-subscription. If you need to pass more than 1000, you need to add logic to enable splitting into <=1000 tokens at a time.  Another thing to note is that all these methods interact with the file that we initially set up and the project on the firebase console.

Types of message:

Firebase handles 2 types of messages as shown in the service above:

  • Notification message - FCM automatically handles displaying the data in the notification without any additional work from the user. The notification message can not have custom values inside it
  • Data message - This is different from the notification message in that you can add custom data elements inside the message and then on the client side, read and use these custom data elements

Rest Controllers

Create two controllers for the endpoints that mobile will consume:

User controller:

This controller handles user creation and token registration from the app.

@RestController
@RequestMapping("users")
public class UserController {
    private final UserRepository userRepository;

    public UserController(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @PostMapping("")
    ResponseEntity<User> handleUser(@RequestBody User user) {
        User newUser = new User();
        if (!TextUtils.isBlank(user.getUserName())) {
            newUser = userRepository.findByUserName(user.getUserName());
            if (newUser == null) {
                User user1 = new User();
                user1.setErrorMessage("User does not exist");
                user1.setSuccess(false);
                return new ResponseEntity<>(user1, HttpStatus.OK);
            }
        } else {
            int usersLength = userRepository.findAll().size();
            if (usersLength == 0) {
                newUser.setUserName("guest1");

            } else {
                int newId = usersLength + 1;
                newUser.setUserName("guest" + newId);
            }
        }

        newUser.setUserToken(user.getUserToken());
        newUser.setSuccess(true);
        userRepository.save(newUser);

        return new ResponseEntity<>(newUser, HttpStatus.OK);

    }
}

Here, we have one method:

  • handleUser - this method handles creating and saving the user in the db. We save the token that is generated from the mobile app and use it for all other scenarios in the backend. It receives the user token and and sends back the user token and username

Notification controller:

This handles everything to do with notifications.

@RestController
public class NotificationController {
    private static final String WEATHER_TOPIC = "weather";
    private final UserRepository userRepository;
    private final WeatherRepository weatherRepository;
    private final FirebaseService firebaseService;

    private Logger logger = LoggerFactory.getLogger(NotificationController.class);

    public NotificationController(UserRepository userRepository, WeatherRepository weatherRepository, FirebaseService firebaseService) {
        this.userRepository = userRepository;
        this.weatherRepository = weatherRepository;
        this.firebaseService = firebaseService;
    }

    @PostMapping("/sendMessage")
    public ResponseEntity<MessageDto> sendTargetedMessage(@RequestBody MessageDto request) {
        User sender = userRepository.findByUserName(request.getSenderUserName());
        User recipient = userRepository.findByUserName(request.getRecipientUserName());
        String response = "No";
        MessageDto message = new MessageDto();

        if (sender == null) {
            message.setMessage("Sender does not exist");
            return new ResponseEntity<>(message, HttpStatus.OK);
        }

        if (recipient == null) {
            message.setMessage("Recipient does not exist");
            return new ResponseEntity<>(message, HttpStatus.OK);
        }

        NotificationDto notification = new NotificationDto();
        notification.setToken(recipient.getUserToken());
        notification.setSubject("New message from " + sender.getUserName());
        notification.setContent(request.getMessage());

        Map<String, String> data = new HashMap<>();
        data.put("screen", "messages");
        data.put("load_data", "true");
        notification.setData(data);

        try {
            if (recipient.isNotifications()) {
                response = firebaseService.sendNotificationWithData(notification);
            }
        } catch (FirebaseMessagingException e) {
            e.printStackTrace();
            message.setMessage("Recipient does not exist");
            return new ResponseEntity<>(message, HttpStatus.OK);
        }
        message.setMessage(response);
        message.setSuccess(true);
        return new ResponseEntity<>(message, HttpStatus.OK);

    }

    @PostMapping("/handleNotifications")
    public ResponseEntity<User> handleUserPreference(@RequestBody User userRequest) {

        User user = userRepository.findByUserName(userRequest.getUserName());
        if (user == null) {
            logger.info("user " + userRequest.getUserName() + " does not exist");
            User newUser = new User();
            newUser.setSuccess(false);
            newUser.setErrorMessage("User does not exist");
            return new ResponseEntity<>(newUser, HttpStatus.OK);
        }

        if (userRequest.isNotifications()) {
            logger.info("notifications turned on for " + user.getUserName());
            user.setNotifications(true);
            userRepository.save(user);
            return new ResponseEntity<>(user, HttpStatus.OK);
        } else {
            logger.info("notifications turned off for " + user.getUserName());
            user.setNotifications(false);
            userRepository.save(user);

            List<String> tokens = Collections.singletonList(user.getUserToken());
            try {
                int num = firebaseService.unSubscribeToTopic(WEATHER_TOPIC, tokens);
                logger.info(num + " token(s) un-subscribed");
            } catch (FirebaseMessagingException e) {
                e.printStackTrace();
            }

            Weather weather = weatherRepository.findByUser(user);
            if (weather != null) {
                logger.info("user " + user.getUserName() + " deleted from weather notifications");
                weatherRepository.delete(weather);
            }
            return new ResponseEntity<>(user, HttpStatus.OK);
        }

    }

    @PostMapping("/subscribe/weather")
    public ResponseEntity<Weather> updateSubscription(@RequestBody SubscriptionDto data) {
        User user = userRepository.findByUserName(data.getUserName());
        Weather weather = new Weather();

        if (user == null) {
            weather.setSuccess(false);
            weather.setErrorMessage("This user does not exist");

            logger.info("The user does not exist");
            return new ResponseEntity<>(weather, HttpStatus.OK);
        }

        if (data.isSubscribe()) {
            weather.setUser(user);
            handleSubscription(user.getUserToken(), true);
            weatherRepository.save(weather);

            logger.info(weather.getUser().getUserName() + " subscribed successfully");
            weather.setSuccess(true);
        } else {
            weather = weatherRepository.findByUser(user);
            handleSubscription(weather.getUser().getUserToken(), false);
            weatherRepository.delete(weather);

            logger.info(weather.getUser().getUserName() + " un-subscribed successfully");
            weather.setSuccess(true);
        }

        return new ResponseEntity<>(weather, HttpStatus.OK);
    }

    private void handleSubscription(String token, boolean subscribe) {

        List<String> tokens = Collections.singletonList(token);

        if (subscribe) {
            try {
                int num = firebaseService.subscribeToTopic(WEATHER_TOPIC, tokens);
                logger.info(num + " token(s) subscribed");
            } catch (FirebaseMessagingException e) {
                e.printStackTrace();
            }
        } else {
            try {
                int num = firebaseService.unSubscribeToTopic(WEATHER_TOPIC, tokens);
                logger.info(num + " token(s) un-subscribed");
            } catch (FirebaseMessagingException e) {
                e.printStackTrace();
            }
        }
    }
}

Let us look at this controller in depth. We inject all the user repositories we need inside the controller constructor. Then we have the following methods:

  • sendTargetedMessage - this method sends a targeted message to a user with the token present. The api receives the sender username, the recepient username and the message to be sent. We do some validations to see if either the sender or recipient is null then we send an error back, if they both exist, we create a new notification object and populate the fields with the data from the api. We also create a new map and pass in the data needed then pass this to the notifications model as we are using the sendNotificationWithData from the service
  • handleUserPreference - this handles turning on/off notifications from the app by editing the user table and adding true/false in the notifications column. The api receives a username and a boolean about the notifications. If validations don't pass, we send back an error message. If they do, we fetch the user token and use this to send an 'unsubscribe to topic' message to FCM. At this point, we also delete all entries in the weather table if the request was to turn notifications off. If a user turns the notifications back on, they are expected to subscribe back to the topic again on the app
  • updateSubscription - this method has two tasks: adding and removing a user from the weather table and also subscribing and unsubscribing a user from the FCM topic. The api receives a username and boolean whether to subscribe or unsubscribe from the topic. Do note that since we have one topic, we have hard-coded it in the app
  • handleSubscription - this is a helper method to handle the actual subscribing and unsubscribing from FCM using the firebase service. The service returns the length of the array that holds the tokens for each scenario as well as an exception that we handle in the respective controller methods

Conclusion

With this, we have finished working on the backend part of this project. We have seen how easy it is to integrate Firebase Cloud Messaging to a backend project and use the methods provided to send notifications to an application. Although we used Java in this project, the firebase admin SDK supports Node.js, Python, Go and C# as well.

The documentation for the SDK is here.

You can also find the Github repo here for further guidance.

In part 2 and part 3, we will build an android app that consumes all the API's we have created in this project.

You've successfully subscribed to Decoded For Devs
Welcome back! You've successfully signed in.
Great! You've successfully signed up.
Your link has expired
Success! Your account is fully activated, you now have access to all content.