Build read receipts using Kotlin
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 of127.0.0.1
used for localhost because this is how the Android emulator recognizes it. Using127.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 TextView
s, 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.
5 March 2018
by Neo Ighodaro