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:
When you have all the requirements you can proceed with the tutorial.
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.
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.
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 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.
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.
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 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:
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
.
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”.
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.