Build read receipts using Kotlin

Introduction

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:

read-receipts-kotlin-demo

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:

1{
2      "name": "realtime-status-update",
3      "version": "1.0.0",
4      "description": "",
5      "main": "index.js",
6      "scripts": {
7        "test": "echo \"Error: no test specified\" && exit 1"
8      },
9      "keywords": [],
10      "author": "",
11      "license": "ISC",
12      "dependencies": {
13        "body-parser": "^1.18.2",
14        "express": "^4.16.2",
15        "pusher": "^1.5.1"
16      }
17    }

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

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

1// Load packages
2    const express = require('express')
3    const bodyParser = require('body-parser')
4    const app = express()
5    const Pusher = require('pusher');
6    
7    // Middleware
8    app.use(bodyParser.json());
9    app.use(bodyParser.urlencoded({ extended: false }));
10    
11    // Temp Variables
12    var userId = 0;
13    var messageId = 0;
14    
15    // Pusher instance
16    var pusher = new Pusher({
17        appId: 'PUSHER_APP_ID',
18        key: 'PUSHER_APP_KEY',
19        secret: 'PUSHER_APP_SECRET',
20        cluster: 'PUSHER_APP_CLUSTER',
21        encrypted: true
22    });
23    
24    // POST: /message
25    app.post('/message', (req, res) => {
26        messageId++;
27        
28        pusher.trigger('my-channel', 'new-message', {
29            "id": messageId,
30            "message": req.query.msg,
31            "sender": req.query.sender,
32        }); 
33    
34        res.json({id: messageId, sender: req.query.sender, message: req.query.msg})
35    })
36    
37    // POST: /delivered
38    app.post('/delivered', (req, res) => {
39        pusher.trigger('my-channel', 'delivery-status', {
40            "id": req.query.messageId,
41            "sender": req.query.sender,
42        }); 
43        
44        res.json({success: 200})
45    })
46    
47    // POST: /auth
48    app.post('/auth', (req, res) => {
49        userId++;
50        res.json({id: "userId" + userId})
51    })
52    
53    // GET: /
54    app.get('/', (req, res, next) => res.json("Working!!!"))
55    
56    // Serve application
57    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:

1$ npm install
2    $ 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:

1implementation 'com.squareup.retrofit2:retrofit:2.3.0'
2    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:

1import retrofit2.Call
2    import retrofit2.http.POST
3    import retrofit2.http.Query
4    
5    interface ApiService {
6    
7        @POST("/message")
8        fun sendMessage(@Query("sender") sender:String, @Query("msg") message:String): Call<String>
9        
10        @POST("/delivered")
11        fun delivered(@Query("sender") sender:String, @Query("messageId") messageId:String): Call<String>
12    
13        @POST("/auth")
14        fun login(): Call<String>
15    }

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:

1import retrofit2.Retrofit
2    import okhttp3.OkHttpClient
3    import retrofit2.converter.scalars.ScalarsConverterFactory
4    
5    class RetrofitClient {
6    
7        companion object {
8            fun getRetrofitClient(): ApiService {
9                val httpClient = OkHttpClient.Builder()
10                val builder = Retrofit.Builder()
11                        .baseUrl("http://10.0.2.2:9000/")
12                        .addConverterFactory(ScalarsConverterFactory.create())
13    
14                val retrofit = builder
15                        .client(httpClient.build())
16                        .build()
17                return retrofit.create(ApiService::class.java)
18            }
19        }
20    }

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:

1<?xml version="1.0" encoding="utf-8"?>
2    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3        xmlns:tools="http://schemas.android.com/tools"
4        android:layout_width="match_parent"
5        android:layout_height="match_parent"
6        android:layout_margin="16dp"
7        tools:context="com.example.android.messagedeliverystatus.LoginActivity">
8        <Button
9            android:layout_gravity="center"
10            android:layout_width="match_parent"
11            android:layout_height="wrap_content"
12            android:id="@+id/login"
13            android:text="Anonymous Login" />
14    </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:

1<?xml version="1.0" encoding="utf-8"?>
2    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
3        xmlns:app="http://schemas.android.com/apk/res-auto"
4        android:layout_width="match_parent"
5        android:layout_height="match_parent"
6        android:layout_margin="16dp">
7    
8        <android.support.v7.widget.RecyclerView
9            android:layout_width="match_parent"
10            android:id="@+id/recyclerView"
11            android:layout_height="match_parent"/>
12    
13        <android.support.design.widget.FloatingActionButton
14            android:id="@+id/fab"
15            android:layout_width="wrap_content"
16            android:layout_height="wrap_content"
17            android:layout_margin="16dp"
18            android:layout_alignParentBottom="true"
19            android:layout_alignParentRight="true"
20            app:srcCompat="@android:drawable/ic_input_add"
21            android:layout_alignParentEnd="true" />
22    </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:

1<?xml version="1.0" encoding="utf-8"?>
2    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
3        xmlns:tools="http://schemas.android.com/tools"
4        android:orientation="vertical"
5        android:layout_margin="16dp"
6        android:layout_width="match_parent"
7        android:layout_height="wrap_content">
8        <TextView
9            android:textAppearance="@style/Base.TextAppearance.AppCompat.Medium"
10            android:layout_width="wrap_content"
11            android:layout_height="wrap_content"
12            tools:text="Neo Ighodaro"
13            android:id="@+id/message" />
14        <TextView
15            android:layout_below="@+id/message"
16            android:textAppearance="@style/Base.TextAppearance.AppCompat.Small"
17            tools:text="sent"
18            android:layout_width="wrap_content"
19            android:layout_height="wrap_content"
20            android:id="@+id/delivery_status" />
21    </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:

1import android.support.v7.widget.RecyclerView
2    import android.view.LayoutInflater
3    import android.view.View
4    import android.view.ViewGroup
5    import android.widget.RelativeLayout
6    import android.widget.TextView
7    import java.util.*
8    
9    class MessageAdapter : RecyclerView.Adapter<MessageAdapter.ViewHolder>() {
10    
11        private var messages = ArrayList<MessageModel>()
12        
13        fun addMessage(message: MessageModel){
14            messages.add(message)
15            notifyDataSetChanged()
16        }
17    
18        override fun getItemCount(): Int {
19            return messages.size
20        }
21    
22        override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
23            return ViewHolder(
24                LayoutInflater.from(parent!!.context)
25                              .inflate(R.layout.custom_chat_row,parent, false)
26            )
27        }
28    
29        override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
30            val params = holder!!.message.layoutParams as RelativeLayout.LayoutParams
31            val params2 = holder!!.deliveryStatus.layoutParams as RelativeLayout.LayoutParams
32    
33            if (messages[position].sender == App.currentUser){
34                params.addRule(RelativeLayout.ALIGN_PARENT_RIGHT)
35                params2.addRule(RelativeLayout.ALIGN_PARENT_RIGHT)
36            }
37    
38            holder.message.text = messages[position].message
39            holder.deliveryStatus.text = messages[position].status
40        }
41    
42        inner class ViewHolder(itemView: View?): RecyclerView.ViewHolder(itemView) {
43            var message: TextView = itemView!!.findViewById(R.id.message)
44            var deliveryStatus: TextView = itemView!!.findViewById(R.id.delivery_status)
45        }
46    
47        fun updateData(id: String) {
48            for(item in messages) {
49                if (item.messageId == id) {
50                    item.status = "delivered"
51                    notifyDataSetChanged()
52                }
53            }
54        }
55    }

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:

1data class MessageModel(var sender:String,
2                            var messageId:String,
3                            var message:String,
4                            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:

1<?xml version="1.0" encoding="utf-8"?>
2    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3        android:orientation="vertical" android:layout_width="match_parent"
4        android:padding="16dp"
5        android:layout_height="match_parent">
6        <EditText
7            android:id="@+id/edit_message"
8            android:layout_width="match_parent"
9            android:layout_height="wrap_content" />
10        <Button
11            android:layout_width="match_parent"
12            android:layout_height="wrap_content"
13            android:id="@+id/send"
14            android:text="Send message"/>
15    </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:

1import android.app.Application
2    
3    class App: Application() {
4        companion object {
5            lateinit var currentUser:String
6        }
7    }

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:

1import android.app.Activity
2    import android.content.Intent
3    import android.os.Bundle
4    import kotlinx.android.synthetic.main.activity_login.*
5    import org.json.JSONObject
6    import retrofit2.Call
7    import retrofit2.Callback
8    import retrofit2.Response
9    
10    class LoginActivity : Activity() {
11    
12        override fun onCreate(savedInstanceState: Bundle?) {
13            super.onCreate(savedInstanceState)
14            
15            setContentView(R.layout.activity_login)
16    
17            login.setOnClickListener {
18                RetrofitClient.getRetrofitClient().login().enqueue(object: Callback<String> {
19                    override fun onFailure(call: Call<String>?, t: Throwable?) {
20                        // Do something on failure
21                    }
22                    
23                    override fun onResponse(call: Call<String>?, response: Response<String>?) {
24                        val jsonObject = JSONObject(response!!.body().toString())
25                        val currentUserId = jsonObject["id"].toString()
26                        App.currentUser = currentUserId
27                        startActivity(Intent(this@LoginActivity, ChatActivity::class.java))
28                    }
29                })
30            }
31        }
32    }

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:

1import android.os.Bundle
2    import android.support.design.widget.FloatingActionButton
3    import android.support.v7.app.AlertDialog
4    import android.support.v7.app.AppCompatActivity
5    import android.support.v7.widget.LinearLayoutManager
6    import android.util.Log
7    import android.widget.Button
8    import android.widget.EditText
9    import com.pusher.client.Pusher
10    import com.pusher.client.PusherOptions
11    import kotlinx.android.synthetic.main.activity_chat.*
12    import org.json.JSONObject
13    import retrofit2.Call
14    import retrofit2.Callback
15    import retrofit2.Response
16    
17    class ChatActivity: AppCompatActivity() {
18    
19        private lateinit var myUserId: String
20        private lateinit var adapter: MessageAdapter
21    
22        override fun onCreate(savedInstanceState: Bundle?) {
23            super.onCreate(savedInstanceState)
24            setContentView(R.layout.activity_chat)
25            myUserId = App.currentUser
26            setupRecyclerView()
27            setupFabListener()
28            setupPusher()
29        }
30    }

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:

1private fun setUpRecyclerView() {
2        recyclerView.layoutManager = LinearLayoutManager(this)
3        adapter = MessageAdapter()
4        recyclerView.adapter = adapter
5    }

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:

1private fun setupFabListener() {
2        val fab: FloatingActionButton = findViewById(R.id.fab)
3        fab.setOnClickListener({
4            createAndShowDialog()
5        })
6    }

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

1private fun createAndShowDialog() {
2        val builder: AlertDialog = AlertDialog.Builder(this).create()
3    
4        // Get the layout inflater
5        val view = this.layoutInflater.inflate(R.layout.dialog_message, null)
6        builder.setMessage("Compose new message")
7        builder.setView(view)
8    
9        val sendMessage: Button = view.findViewById(R.id.send)
10        val editTextMessage: EditText = view.findViewById(R.id.edit_message)
11        sendMessage.setOnClickListener({
12    
13            if (editTextMessage.text.isNotEmpty())
14                RetrofitClient.getRetrofitClient().sendMessage(myUserId, editTextMessage.text.toString()).enqueue(object : Callback<String> {
15                    override fun onResponse(call: Call<String>?, response: Response<String>?) {
16                        // message has sent
17                        val jsonObject = JSONObject(response!!.body())
18                        val newMessage = MessageModel(
19                                jsonObject["sender"].toString(),
20                                jsonObject["id"].toString(),
21                                jsonObject["message"].toString(),
22                                "sent"
23                        )
24                        adapter.addMessage(newMessage)
25                        builder.dismiss()
26                    }
27    
28                    override fun onFailure(call: Call<String>?, t: Throwable?) {
29                        // Message could not send
30                    }
31                })
32        })
33    
34        builder.show()
35    }

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:

1private fun setupPusher() {
2        val options = PusherOptions()
3        options.setCluster("PUSHER_APP_CLUSTER")
4    
5        val pusher = Pusher("PUSHER_APP_KEY", options)
6        val channel = pusher.subscribe("my-channel")
7    
8        channel.bind("new_message") { channelName, eventName, data ->
9            val jsonObject = JSONObject(data)
10            val sender = jsonObject["sender"].toString()
11    
12            if (sender != myUserId) {
13                // this message is not from me, instead, it is from another user
14                val newMessage = MessageModel(
15                        sender,
16                        jsonObject["id"].toString(),
17                        jsonObject["message"].toString(),
18                        ""
19                )
20                
21                runOnUiThread {
22                    adapter.addMessage(newMessage)
23                }
24    
25                // tell the sender that his message has delivered
26                RetrofitClient.getRetrofitClient().delivered(sender, jsonObject["id"].toString()).enqueue(object : Callback<String> {
27                
28                    override fun onResponse(call: Call<String>?, response: Response<String>?) {
29                        // I have told the sender that his message delivered
30                    }
31    
32                    override fun onFailure(call: Call<String>?, t: Throwable?) {
33                        // I could not tell the sender
34                    }
35                })
36            }
37        }
38    
39        channel.bind("delivery-status") { channelName, eventName, data ->
40            val jsonObject = JSONObject(data)
41            val sender = jsonObject["sender"]
42            
43            if (sender == myUserId) {
44                runOnUiThread {
45                    adapter.updateData(jsonObject["id"].toString())
46                }
47            }
48        }
49    
50        pusher.connect()
51    }

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.