Build read receipts using Kotlin

  • Neo Ighodaro
March 5th, 2018
A basic understanding of Kotlin and Node.js is needed to follow this tutorial.

If you have ever used messaging services like iMessage, WhatsApp or Messenger you’ll notice that when you send a message, you get a ‘Delivered’ notice when the message is delivered. This helps improve engagement because knowing when the message hits the users device is just good information to have.

In this article, we will consider how to build a read receipts using the Kotlin and Pusher. We will be building a simple messaging application to demonstrate this feature.

Here is a screen recording of the application we will be building in action:

Prerequisites

  • Knowledge of the Kotlin programming language.
  • Android Studio installed locally (version 3.0.1 or newer is recommended).
  • Node.js and NPM installed on your machine.
  • Basic knowledge of JavaScript.
  • A Pusher application. Create one here.

When you have all the requirements you can proceed with the tutorial.

Setting up a Node.js Backend

For our application, we need a server to trigger the messages and delivery status to the Pusher channel and events we subscribe to. For the backend, we will use the Express Node.js framework.

Create a new folder for your project, we will name ours message-delivery-backend. Open the empty folder, create a package.json file and paste this:

    {
      "name": "realtime-status-update",
      "version": "1.0.0",
      "description": "",
      "main": "index.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "dependencies": {
        "body-parser": "^1.18.2",
        "express": "^4.16.2",
        "pusher": "^1.5.1"
      }
    }

This file contains dependencies needed by our server and some other key details for the server.

Next, let’s create the index.js file:

    // Load packages
    const express = require('express')
    const bodyParser = require('body-parser')
    const app = express()
    const Pusher = require('pusher');

    // Middleware
    app.use(bodyParser.json());
    app.use(bodyParser.urlencoded({ extended: false }));

    // Temp Variables
    var userId = 0;
    var messageId = 0;

    // Pusher instance
    var pusher = new Pusher({
        appId: 'PUSHER_APP_ID',
        key: 'PUSHER_APP_KEY',
        secret: 'PUSHER_APP_SECRET',
        cluster: 'PUSHER_APP_CLUSTER',
        encrypted: true
    });

    // POST: /message
    app.post('/message', (req, res) => {
        messageId++;

        pusher.trigger('my-channel', 'new-message', {
            "id": messageId,
            "message": req.query.msg,
            "sender": req.query.sender,
        }); 

        res.json({id: messageId, sender: req.query.sender, message: req.query.msg})
    })

    // POST: /delivered
    app.post('/delivered', (req, res) => {
        pusher.trigger('my-channel', 'delivery-status', {
            "id": req.query.messageId,
            "sender": req.query.sender,
        }); 

        res.json({success: 200})
    })

    // POST: /auth
    app.post('/auth', (req, res) => {
        userId++;
        res.json({id: "userId" + userId})
    })

    // GET: /
    app.get('/', (req, res, next) => res.json("Working!!!"))

    // Serve application
    app.listen(9000, _ => console.log('Running application...'))

In the code above, we have the messageId variable to giver every message a unique ID and the userId variable to give every user a unique id. This will help us clearly distinguish messages and users so as to know when and where to place the delivery status tags under each message.

You are expected to add the keys from your dashboard into the above code replacing the PUSHER_APP_* values.

Open your terminal, and cd to the root directory of your project. Run the commands below to install the NPM packages and start our Node.js server:

    $ npm install
    $ node index.js

With this, our server is up and running on port 9000.

Setting up the Android client

Creating a Project

Open Android studio, create a new project and fill in your application name and package name. It is recommended that your minimum SDK should not be less than API 14. Then, select an ‘Empty Activity’, name it LoginActivity and click finish.

Setting up Retrofit

Retrofit is a type-safe HTTP client that will enable us make requests to our node server. The first step in making this happen is adding the Retrofit dependency. In your app module build.gradle file, add the following to the dependencies list:

    implementation 'com.squareup.retrofit2:retrofit:2.3.0'
    implementation 'com.squareup.retrofit2:converter-scalars:2.3.0'

Sync the gradle files after adding the dependencies. Thereafter, we create an interface that provides the endpoints we will access during this demo. Create a new Kotlin class, name it ApiService.kt and paste this:

    import retrofit2.Call
    import retrofit2.http.POST
    import retrofit2.http.Query

    interface ApiService {

        @POST("/message")
        fun sendMessage(@Query("sender") sender:String, @Query("msg") message:String): Call<String>

        @POST("/delivered")
        fun delivered(@Query("sender") sender:String, @Query("messageId") messageId:String): Call<String>

        @POST("/auth")
        fun login(): Call<String>
    }

In the code above, we have interfaced our three endpoints. The first, /message, is where we will send the message to, /delivered where we will tell the server that a message with a particular id has delivered, and finally, /auth for a make-believe user login.

Next, create a class that that will provide a Retrofit object to enable us make requests. Create a new Kotlin class named RetrofitClient.kt:

    import retrofit2.Retrofit
    import okhttp3.OkHttpClient
    import retrofit2.converter.scalars.ScalarsConverterFactory

    class RetrofitClient {

        companion object {
            fun getRetrofitClient(): ApiService {
                val httpClient = OkHttpClient.Builder()
                val builder = Retrofit.Builder()
                        .baseUrl("http://10.0.2.2:9000/")
                        .addConverterFactory(ScalarsConverterFactory.create())

                val retrofit = builder
                        .client(httpClient.build())
                        .build()
                return retrofit.create(ApiService::class.java)
            }
        }
    }  

We are using the 10.0.2.2 instead of 127.0.0.1 used for localhost because this is how the Android emulator recognizes it. Using 127.0.0.1 will not work.

That’s all for setting up the Retrofit client. Let’s move on to setting up Pusher.

Setting up Pusher

Pusher provides the realtime functionalities we need to know when a message has been delivered to another user. To use Pusher, we need to add the dependency in our app-module build.gradle file:

    implementation 'com.pusher:pusher-java-client:1.5.0'

Sync the gradle files to make the library available for use. That’s all.

Designing Our Layouts

Our app will have two screens. We already have the LoginActivity created. We need to create the second activity and name it ChatActivity. Our LoginActivity will have just one button to log the user in and its layout file activity_login.xml will look have this:

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_margin="16dp"
        tools:context="com.example.android.messagedeliverystatus.LoginActivity">
        <Button
            android:layout_gravity="center"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:id="@+id/login"
            android:text="Anonymous Login" />
    </LinearLayout>

The activity_chat.xml will contain a RecyclerView and a FloatingActionButton. For these views to be available, you have to add the design support library in the build.gradle file:

    implementation 'com.android.support:design:26.1.0'

Sync your gradle file to keep the project up to date. Next, paste this code in the activity_chat.xml file:

    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_margin="16dp">

        <android.support.v7.widget.RecyclerView
            android:layout_width="match_parent"
            android:id="@+id/recyclerView"
            android:layout_height="match_parent"/>

        <android.support.design.widget.FloatingActionButton
            android:id="@+id/fab"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="16dp"
            android:layout_alignParentBottom="true"
            android:layout_alignParentRight="true"
            app:srcCompat="@android:drawable/ic_input_add"
            android:layout_alignParentEnd="true" />
    </RelativeLayout>

The recycler view will contain the chat messages while the FloatingActionButton will open a dialog to help us add a new message. There are other things that go with a recycler view: a custom layout of how a single row looks like, an adapter that handles items on the list and sometimes a custom model class.

The model class mimics the data that each item in the list will have. So, we have to create these three things. Create a new layout named custom_chat_row.xml and paste this:

    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:orientation="vertical"
        android:layout_margin="16dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <TextView
            android:textAppearance="@style/Base.TextAppearance.AppCompat.Medium"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            tools:text="Neo Ighodaro"
            android:id="@+id/message" />
        <TextView
            android:layout_below="@+id/message"
            android:textAppearance="@style/Base.TextAppearance.AppCompat.Small"
            tools:text="sent"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/delivery_status" />
    </RelativeLayout>

Each row will be styled according to our layout above. There are two TextViews, one to show the main message and the other to show the delivery status which can either be send or delivered. Next, create a new file named MessageAdapter.kt and paste this:

    import android.support.v7.widget.RecyclerView
    import android.view.LayoutInflater
    import android.view.View
    import android.view.ViewGroup
    import android.widget.RelativeLayout
    import android.widget.TextView
    import java.util.*

    class MessageAdapter : RecyclerView.Adapter<MessageAdapter.ViewHolder>() {

        private var messages = ArrayList<MessageModel>()

        fun addMessage(message: MessageModel){
            messages.add(message)
            notifyDataSetChanged()
        }

        override fun getItemCount(): Int {
            return messages.size
        }

        override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
            return ViewHolder(
                LayoutInflater.from(parent!!.context)
                              .inflate(R.layout.custom_chat_row,parent, false)
            )
        }

        override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
            val params = holder!!.message.layoutParams as RelativeLayout.LayoutParams
            val params2 = holder!!.deliveryStatus.layoutParams as RelativeLayout.LayoutParams

            if (messages[position].sender == App.currentUser){
                params.addRule(RelativeLayout.ALIGN_PARENT_RIGHT)
                params2.addRule(RelativeLayout.ALIGN_PARENT_RIGHT)
            }

            holder.message.text = messages[position].message
            holder.deliveryStatus.text = messages[position].status
        }

        inner class ViewHolder(itemView: View?): RecyclerView.ViewHolder(itemView) {
            var message: TextView = itemView!!.findViewById(R.id.message)
            var deliveryStatus: TextView = itemView!!.findViewById(R.id.delivery_status)
        }

        fun updateData(id: String) {
            for(item in messages) {
                if (item.messageId == id) {
                    item.status = "delivered"
                    notifyDataSetChanged()
                }
            }
        }
    }

The adapter handles the display of items. We used the overridden functions to structure how many items will be on the list, how each row should be styled, and how o get data from each row. We also created our own functions to add a new message to the list and update an item on the list.

Next, create a new class named MessageModel.kt and paste this:

    data class MessageModel(var sender:String,
                            var messageId:String,
                            var message:String,
                            var status:String)

This is known as a data class. A data class is used to hold data. This replaces the usual POJO (Plain Old Java Object) classes we would have created if we were using Java. We will be using a dialog to send messages in this demo, so we need to create a layout for it.

Create a new layout file named dialog_message.xml and past this:

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:orientation="vertical" android:layout_width="match_parent"
        android:padding="16dp"
        android:layout_height="match_parent">
        <EditText
            android:id="@+id/edit_message"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />
        <Button
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:id="@+id/send"
            android:text="Send message"/>
    </LinearLayout>

The layout contains an EditText for text input and a Button to send the message and they are wrapped in a vertical LinearLayout.

Adding logic to our application

We will create a class that extends Application. Create a new class named App.kt and paste this:

    import android.app.Application

    class App: Application() {
        companion object {
            lateinit var currentUser:String
        }
    }

This class will be used to store our unique user ID globally so that it can easily be accessed by all other classes.

Next, open the LoginActivity.kt class and paste this:

    import android.app.Activity
    import android.content.Intent
    import android.os.Bundle
    import kotlinx.android.synthetic.main.activity_login.*
    import org.json.JSONObject
    import retrofit2.Call
    import retrofit2.Callback
    import retrofit2.Response

    class LoginActivity : Activity() {

        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)

            setContentView(R.layout.activity_login)

            login.setOnClickListener {
                RetrofitClient.getRetrofitClient().login().enqueue(object: Callback<String> {
                    override fun onFailure(call: Call<String>?, t: Throwable?) {
                        // Do something on failure
                    }

                    override fun onResponse(call: Call<String>?, response: Response<String>?) {
                        val jsonObject = JSONObject(response!!.body().toString())
                        val currentUserId = jsonObject["id"].toString()
                        App.currentUser = currentUserId
                        startActivity(Intent(this@LoginActivity, ChatActivity::class.java))
                    }
                })
            }
        }
    }

In this activity, we assigned a click listener to our button so when the button is clicked, a request is then made to the /auth endpoint of the server to log the user in. A unique user ID is returned to the client. After the ID is received, we store it in our App class and open the next activity, ChatActivity.

Next, create a file called ChatActivity.kt and paste the following into the file:

    import android.os.Bundle
    import android.support.design.widget.FloatingActionButton
    import android.support.v7.app.AlertDialog
    import android.support.v7.app.AppCompatActivity
    import android.support.v7.widget.LinearLayoutManager
    import android.util.Log
    import android.widget.Button
    import android.widget.EditText
    import com.pusher.client.Pusher
    import com.pusher.client.PusherOptions
    import kotlinx.android.synthetic.main.activity_chat.*
    import org.json.JSONObject
    import retrofit2.Call
    import retrofit2.Callback
    import retrofit2.Response

    class ChatActivity: AppCompatActivity() {

        private lateinit var myUserId: String
        private lateinit var adapter: MessageAdapter

        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_chat)
            myUserId = App.currentUser
            setupRecyclerView()
            setupFabListener()
            setupPusher()
        }
    }

This class is minimized into various functions for proper clarity. Before getting to the functions, we have a class variable which takes in the value of our unique user ID from the App class, this is for easy accessibility.

The first function setupRecyclerView() is used to initialize the recycler view and its adapter. Add the function below to the class:

    private fun setUpRecyclerView() {
        recyclerView.layoutManager = LinearLayoutManager(this)
        adapter = MessageAdapter()
        recyclerView.adapter = adapter
    }

Next, we created a vertical layout manager and assigned it to our recycler view, we also initialized MessageAdapter and assigned it to the recycler view as well.

The next function, setupFabListener() is used to add a listener to the FloatingActionButton. Paste the function below into the same class:

    private fun setupFabListener() {
        val fab: FloatingActionButton = findViewById(R.id.fab)
        fab.setOnClickListener({
            createAndShowDialog()
        })
    }

The next function is createAndShowDialog(). Paste the function below into the same class:

    private fun createAndShowDialog() {
        val builder: AlertDialog = AlertDialog.Builder(this).create()

        // Get the layout inflater
        val view = this.layoutInflater.inflate(R.layout.dialog_message, null)
        builder.setMessage("Compose new message")
        builder.setView(view)

        val sendMessage: Button = view.findViewById(R.id.send)
        val editTextMessage: EditText = view.findViewById(R.id.edit_message)
        sendMessage.setOnClickListener({

            if (editTextMessage.text.isNotEmpty())
                RetrofitClient.getRetrofitClient().sendMessage(myUserId, editTextMessage.text.toString()).enqueue(object : Callback<String> {
                    override fun onResponse(call: Call<String>?, response: Response<String>?) {
                        // message has sent
                        val jsonObject = JSONObject(response!!.body())
                        val newMessage = MessageModel(
                                jsonObject["sender"].toString(),
                                jsonObject["id"].toString(),
                                jsonObject["message"].toString(),
                                "sent"
                        )
                        adapter.addMessage(newMessage)
                        builder.dismiss()
                    }

                    override fun onFailure(call: Call<String>?, t: Throwable?) {
                        // Message could not send
                    }
                })
        })

        builder.show()
    }

This function builds a dialog and displays it for the user to enter a new message. When the send button on the dialog is clicked, the message entered is sent to the server through the /message endpoint.

After the message is received, the server assigns a unique ID to the message then Pusher triggers data which contains the message just received together with its ID and the sender’s ID to the new-message event.

Meanwhile, as soon as a message is sent, we add it to our recycler view and update the adapter using the adapter.addMessage() function.

The final function to add to the class is setupPusher() , this will initialize Pusher and listen for events. Paste the function below into the class:

    private fun setupPusher() {
        val options = PusherOptions()
        options.setCluster("PUSHER_APP_CLUSTER")

        val pusher = Pusher("PUSHER_APP_KEY", options)
        val channel = pusher.subscribe("my-channel")

        channel.bind("new_message") { channelName, eventName, data ->
            val jsonObject = JSONObject(data)
            val sender = jsonObject["sender"].toString()

            if (sender != myUserId) {
                // this message is not from me, instead, it is from another user
                val newMessage = MessageModel(
                        sender,
                        jsonObject["id"].toString(),
                        jsonObject["message"].toString(),
                        ""
                )

                runOnUiThread {
                    adapter.addMessage(newMessage)
                }

                // tell the sender that his message has delivered
                RetrofitClient.getRetrofitClient().delivered(sender, jsonObject["id"].toString()).enqueue(object : Callback<String> {

                    override fun onResponse(call: Call<String>?, response: Response<String>?) {
                        // I have told the sender that his message delivered
                    }

                    override fun onFailure(call: Call<String>?, t: Throwable?) {
                        // I could not tell the sender
                    }
                })
            }
        }

        channel.bind("delivery-status") { channelName, eventName, data ->
            val jsonObject = JSONObject(data)
            val sender = jsonObject["sender"]

            if (sender == myUserId) {
                runOnUiThread {
                    adapter.updateData(jsonObject["id"].toString())
                }
            }
        }

        pusher.connect()
    }

In the above snippets, we initialized Pusher, subscribed to a channel - my-channel and listened to events. We have two events: the first is new_message which enables us receive new messages. Since messages sent by us are already added to the list, we won’t add them here again. Instead, we only look for messages from other users hence the need for a unique user ID.

When we receive messages from other users, we send a network call to the /delivered endpoint passing the message ID and the current sender’s ID as a parameter. The endpoint then triggers a message to the delivery-status event to alert the the sender at the other end that the message has been delivered. Note that from our server setup, each message also has a unique ID.

The second event we listen to is the delivery-status event. When we receive data in this event, we check the data received to see if the sender matches the current user logged in user and if it does, we send the message ID to our updateData() function. This function checks the list to see which message has the unique ID in question and updates it with “delivered”.

Conclusion

In this article, we have been able to demonstrate how to implement a read receipt feature in Kotlin. Hopefully, you have picked up a few things on how you can use Pusher and Kotlin.

  • Channels

© 2018 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.