Build an Android messenger app with online presence using Kotlin

Introduction

When building a chat application, it is essential to have an online presence feature. It is essential because your users will like to know when their friends are online and are more likely to respond to their messages.

In this article, we will be building a messenger app with online presence using Pusher Channels, Kotlin and Node.js.

Here is a demo of what we will build:

android-messenger-presence-demo

Prerequisites

To follow along you need the following requirements:

  • A Pusher Channel app. You can create one here.
  • Android Studio installed on your machine. You can check here for the latest stable version. A minimum of version 3.0 is recommended.
  • Basic knowledge of Android development and the Android Studio IDE.
  • Basic knowledge of Kotlin. Here are the official docs.
  • Node.js and NPM (Node Package Manager) installed on your machine. Download here.
  • Mongo DB installed on your machine. You can install it following the instructions here.

Building the backend server

Our server will be built using Node.js. To start, create a new project directory:

    $ mkdir backend-server

Next, create a new index.js file inside the project directory and paste the following code:

1// File: ./index.js
2    var express = require('express');
3    var bodyParser = require('body-parser');
4    const mongoose = require('mongoose');
5    var Pusher = require('pusher');
6    
7    var app = express();
8    
9    app.use(bodyParser.json());
10    app.use(bodyParser.urlencoded({ extended: false }));
11    
12    var pusher = new Pusher({
13      appId: 'PUSHER_APP_ID',
14      key: 'PUSHER_APP_KEY',
15      secret: 'PUSHER_APP_SECRET',
16      cluster: 'PUSHER_APP_CLUSTER'
17    });
18    
19    mongoose.connect('mongodb://127.0.0.1/db');
20    
21    const Schema = mongoose.Schema;
22    const userSchema = new Schema({
23        name: { type: String, required: true, },
24        count: {type: Number}
25    });
26    
27    var User = mongoose.model('User', userSchema);
28    userSchema.pre('save', function(next) {
29        if (this.isNew) {
30            User.count().then(res => {
31              this.count = res; // Increment count
32              next();
33            });
34          } else {
35            next();
36          }
37    });
38    
39    module.exports = User;
40    
41    var currentUser;
42    
43    /* 
44    =================================
45    We will add our endpoints here!!!
46    =================================
47    */
48    
49    var port = process.env.PORT || 5000;
50    
51    app.listen(port);

In the snippet above, we initialized Pusher, Express, and MongoDB. We are using Moongose to connect to our MongoDB instance.

Replace the PUSHER_APP_* keys with the ones on your Pusher dashboard.

Now let’s add our endpoints. The first endpoint we will add will be to log a user in. Paste the code below in your index.js file below the currentUser declaration:

1// File: ./index.js
2    
3    // [...]
4    
5    app.post('/login', (req,res) => {
6        User.findOne({name: req.body.name}, (err, user) => {
7            if (err) {
8                res.send("Error connecting to database");
9            }
10            
11            // User exists
12            if (user) {
13                currentUser = user;
14                return res.status(200).send(user)
15            }
16            
17            let newuser = new User({name: req.body.name});
18    
19            newuser.save(function(err) {
20                if (err) throw err;
21            });
22            
23            currentUser = newuser;
24            res.status(200).send(newuser)
25        });
26    })
27    
28    // [...]

This endpoint receives a username with the request, and either create a new user or returns the data of the existing user.

Let’s add the next endpoint below the one above:

1// File: ./index.js
2    
3    // [...]
4    
5    app.get('/users', (req,res) => {
6        User.find({}, (err, users) => {
7            if (err) throw err;
8            res.send(users);
9        });
10    })
11    
12    // [...]

This endpoint above fetches all the users from the database and returns them.

Since we will be using a Pusher presence channel, we need an endpoint to authenticate the user. In the same file, paste this code below the endpoint above:

1// File: ./index.js
2    
3    // [...]
4    
5    app.post('/pusher/auth/presence', (req, res) => {
6        let socketId = req.body.socket_id;
7        let channel = req.body.channel_name;
8      
9        let presenceData = {
10            user_id: currentUser._id,
11            user_info: {count: currentUser.count, name: currentUser.name}
12        };
13      
14        let auth = pusher.authenticate(socketId, channel, presenceData);
15    
16        res.send(auth);
17    });
18    
19    // [...]

Since we are going to be using private channels, we need an endpoint for authentication. Add the following endpoint below the endpoint above:

1// File: ./index.js
2    
3    // [...]
4    
5    app.post('/pusher/auth/private', (req, res) => {
6        res.send(pusher.authenticate(req.body.socket_id, req.body.channel_name));
7    });
8    
9    // [...]
10
11Finally, the last endpoint will be to trigger an event `new-message` to a channel. Add the endpoint below the last one:
12
13
14    // File: ./index.js
15    
16    // [...]
17    
18    app.post('/send-message', (req, res) => {
19        let payload = {message: req.body.message, sender_id: req.body.sender_id}
20        pusher.trigger(req.body.channel_name, 'new-message', payload);
21        res.send(200);
22    });
23    
24    // [...]

After adding all the endpoints, install the necessary NPM packages by running this command:

    $ npm install express body-parser mongoose pusher

Before you run your application, make sure MongoDB is running already using this command:

1$ mongod --dbpath C:\MongoDB\data\db # Windows
2    $ mongod --dbpath=/path/to/db/directory # Mac or Linux

Now you can run your application using the command below:

    $ node index.js

Your app will be available here: http://localhost:5000.

Building our Android application

Create your Android project. In the wizard, enter your project name, let’s say MessengerApp. Next, enter your package name. You can use a minimum SDK of 19 then choose an Empty Activity. On the next page, change the Activity Name to LoginActivity. After this, Android Studio will build your project for you.

Now that we have the project, let’s add the required dependencies for our app. Open your app module build.gradle file and add these:

1// File ../app/build.gradle
2    dependencies {
3      // [...]
4      
5      implementation 'com.android.support:design:28+'
6      implementation 'com.pusher:pusher-java-client:1.6.0'
7      implementation "com.squareup.retrofit2:retrofit:2.4.0"
8      implementation "com.squareup.retrofit2:converter-scalars:2.4.0"
9      implementation 'com.squareup.retrofit2:converter-gson:2.3.0'
10    }

Notably, we added the dependencies for Retrofit and Pusher. Retrofit is an HTTP client library used for network calls. We added the design library dependency too as we want to use some classes from it. Sync your gradle files to pull in the dependencies.

Next, let’s prepare our app to make network calls. Retrofit requires an interface to know the endpoints to be accessed.

Create a new interface named ApiService and paste this:

1// File: ./app/src/main/java/com/example/messengerapp/ApiService.kt
2    import okhttp3.RequestBody
3    import retrofit2.Call
4    import retrofit2.http.Body
5    import retrofit2.http.GET
6    import retrofit2.http.POST
7    
8    interface ApiService {
9    
10      @POST("/login")
11      fun login(@Body body:RequestBody): Call<UserModel>
12    
13      @POST("/send-message")
14      fun sendMessage(@Body body:RequestBody): Call<String>
15    
16      @GET("/users")
17      fun getUsers(): Call<List<UserModel>>
18    }

Here, we have declared three endpoints. They are for logging in, sending messages and fetching users. Notice that in some of our responses, we return Call<UserModel>. Let’s create the UserModel. Create a new class called UserModel and paste the following:

1// File: ./app/src/main/java/com/example/messengerapp/UserModel.kt
2    import com.google.gson.annotations.Expose
3    import com.google.gson.annotations.SerializedName
4    
5    data class UserModel(@SerializedName("_id") @Expose var id: String,
6                         @SerializedName("name") @Expose var name: String,
7                         @SerializedName("count") @Expose var count: Int,
8                         var online:Boolean = false)

Above, we used a data class so that some other functions required for model classes such as toString, hashCode are added to the class by default.

We are expecting only the values for the id and name from the server. We added the online property so we can update later on.

Next, create a new class named RetrofitInstance and paste the following code:

1// File: ./app/src/main/java/com/example/messengerapp/RetrofitInstance.kt
2    import okhttp3.OkHttpClient
3    import retrofit2.Retrofit
4    import retrofit2.converter.gson.GsonConverterFactory
5    import retrofit2.converter.scalars.ScalarsConverterFactory
6    
7    class RetrofitInstance {
8    
9      companion object {
10        val retrofit: ApiService by lazy {
11          val httpClient = OkHttpClient.Builder()
12          val builder = Retrofit.Builder()
13              .baseUrl("http://10.0.2.2:5000/")
14              .addConverterFactory(ScalarsConverterFactory.create())
15              .addConverterFactory(GsonConverterFactory.create())
16    
17          val retrofit = builder
18              .client(httpClient.build())
19              .build()
20          retrofit.create(ApiService::class.java)
21        }
22      }
23    }

This class contains a class variable called retrofit. It provides us with an instance for Retrofit that we will reference in more than one class.

Finally, to request for the internet access permission update the AndroidManifest.xml file like so:

1// File: ./app/src/main/ApiService.kt
2    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
3      package="com.example.messengerapp">
4    
5      <uses-permission android:name="android.permission.INTERNET" />
6      [...]
7    
8    </manifest>

Now we can make requests using Retrofit.

The next feature we will implement is login. Open the already created LoginActivity layout file activity_login.xml file and paste this:

1// File: ./app/src/main/res/layout/activity_login.xml
2    <?xml version="1.0" encoding="utf-8"?>
3    <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
4      xmlns:app="http://schemas.android.com/apk/res-auto"
5      xmlns:tools="http://schemas.android.com/tools"
6      android:layout_width="match_parent"
7      android:layout_height="match_parent"
8      android:layout_margin="20dp"
9      tools:context=".LoginActivity">
10    
11      <EditText
12        android:id="@+id/editTextUsername"
13        android:layout_width="match_parent"
14        android:layout_height="wrap_content"
15        app:layout_constraintBottom_toBottomOf="parent"
16        app:layout_constraintLeft_toLeftOf="parent"
17        app:layout_constraintRight_toRightOf="parent"
18        app:layout_constraintTop_toTopOf="parent" />
19    
20      <Button
21        android:id="@+id/loginButton"
22        android:layout_width="match_parent"
23        android:layout_height="wrap_content"
24        android:text="Login"
25        app:layout_constraintTop_toBottomOf="@+id/editTextUsername" />
26    
27    </android.support.constraint.ConstraintLayout>

This layout contains an input field to take the username and a button to make a login request.

Next, open the LoginActivity.Kt file and paste this:

1// File: ./app/src/main/java/com/example/messengerapp/LoginActivity.kt
2    import android.content.Intent
3    import android.os.Bundle
4    import android.support.v7.app.AppCompatActivity
5    import android.util.Log
6    import kotlinx.android.synthetic.main.activity_login.*
7    import okhttp3.MediaType
8    import okhttp3.RequestBody
9    import org.json.JSONObject
10    import retrofit2.Call
11    import retrofit2.Callback
12    import retrofit2.Response
13    
14    class LoginActivity : AppCompatActivity() {
15    
16      override fun onCreate(savedInstanceState: Bundle?) {
17        super.onCreate(savedInstanceState)
18        setContentView(R.layout.activity_login)
19        loginButton.setOnClickListener {
20          if (editTextUsername.text.isNotEmpty()) {
21            loginFunction(editTextUsername.text.toString())
22          }
23        }
24      }
25    
26      private fun loginFunction(name:String) {
27        val jsonObject = JSONObject()
28        jsonObject.put("name", name)
29        
30        val jsonBody = RequestBody.create(
31            MediaType.parse("application/json; charset=utf-8"), 
32            jsonObject.toString()
33        )
34    
35        RetrofitInstance.retrofit.login(jsonBody).enqueue(object:Callback<UserModel> {
36          override fun onFailure(call: Call<UserModel>?, t: Throwable?) {
37            Log.i("LoginActivity",t!!.localizedMessage)
38          }
39    
40          override fun onResponse(call: Call<UserModel>?, response: Response<UserModel>?) {
41            if (response!!.code() == 200) {
42              Singleton.getInstance().currentUser = response.body()!!
43              startActivity(Intent(this@LoginActivity,ContactListActivity::class.java))
44              finish()
45            }
46          }
47        })
48      }
49    }

In the file, we set up a listener for our login button so that when it is clicked, we can send the text to the server for authentication. We also stored the logged in user in a singleton class so that we can access the user’s details later.

Create a new class called Singleton and paste this:

1// File: ./app/src/main/java/com/example/messengerapp/RetrofitInstance.kt
2    class Singleton {
3      companion object {
4        private val ourInstance = Singleton()
5        fun getInstance(): Singleton {
6          return ourInstance
7        }
8      }
9      lateinit var currentUser: UserModel
10    }

With this class, we will have access to the currentUser, which is the logged in user.

Next, let’s create a new activity named ContactListActivity. For now, leave the class empty and open the corresponding layout file named activity_contact_list and paste the following:

1// File: ./app/src/main/res/layout/activity_contact_list.xml
2    <?xml version="1.0" encoding="utf-8"?>
3    <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
4      xmlns:app="http://schemas.android.com/apk/res-auto"
5      xmlns:tools="http://schemas.android.com/tools"
6      android:layout_width="match_parent"
7      android:layout_height="match_parent"
8      tools:context=".ContactListActivity">
9    
10      <android.support.v7.widget.RecyclerView
11        android:layout_width="match_parent"
12        android:id="@+id/recyclerViewUserList"
13        android:layout_height="match_parent"/>
14    
15    </android.support.constraint.ConstraintLayout>

The layout contains a recycler view, which will give us all the list of our contacts fetched from the database. Since we are displaying items in a list, we will need an adapter class to manage how items are inflated to the layout.

Create a new class named ContactRecyclerAdapter and paste this:

1// File: ./app/src/main/java/com/example/messengerapp/ContactRecyclerAdapter.kt
2    import android.support.v7.widget.RecyclerView
3    import android.view.LayoutInflater
4    import android.view.View
5    import android.view.ViewGroup
6    import android.widget.ImageView
7    import android.widget.TextView
8    import java.util.*
9    
10    class ContactRecyclerAdapter(private var list: ArrayList<UserModel>, private var listener: UserClickListener)
11      : RecyclerView.Adapter<ContactRecyclerAdapter.ViewHolder>() {
12      
13      override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
14        return ViewHolder(LayoutInflater.from(parent.context)
15            .inflate(R.layout.user_list_row, parent, false))
16      }
17    
18      override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(list[position])
19    
20      override fun getItemCount(): Int = list.size
21    
22      fun showUserOnline(updatedUser: UserModel) {
23        list.forEachIndexed { index, element ->
24          if (updatedUser.id == element.id) {
25            updatedUser.online = true
26            list[index] = updatedUser
27            notifyItemChanged(index)
28          }
29    
30        }
31      }
32    
33      fun showUserOffline(updatedUser: UserModel) {
34        list.forEachIndexed { index, element ->
35          if (updatedUser.id == element.id) {
36            updatedUser.online = false
37            list[index] = updatedUser
38            notifyItemChanged(index)
39          }
40        }
41      }
42    
43      fun add(user: UserModel) {
44        list.add(user)
45        notifyDataSetChanged()
46      }
47    
48      inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
49        private val nameTextView: TextView = itemView.findViewById(R.id.usernameTextView)
50        private val presenceImageView: ImageView = itemView.findViewById(R.id.presenceImageView)
51    
52        fun bind(currentValue: UserModel) = with(itemView) {
53          this.setOnClickListener {
54            listener.onUserClicked(currentValue)
55          }
56          nameTextView.text = currentValue.name
57          if (currentValue.online){
58            presenceImageView.setImageDrawable(this.context.resources.getDrawable(R.drawable.presence_icon_online))
59          } else {
60            presenceImageView.setImageDrawable(this.context.resources.getDrawable(R.drawable.presence_icon))
61    
62          }
63    
64        }
65      }
66    
67      interface UserClickListener {
68        fun onUserClicked(user: UserModel)
69      }
70    }

This adapter has some overridden methods and some custom methods.

The onCreateViewHolder inflates how each row will look like. onBindViewHolder binds the data to each item by calling the bind method in the inner ViewHolder class. The getItemCount gives the size of the list.

For our custom methods, showUserOffline updates the user and shows when they are offline. While showUserOnline does the opposite. Finally, we have the add method, which adds a new contact to the list and refreshes it.

In the adapter class above, we used a new layout named user_list_row. Create a new layout user_list_row and paste this:

1// File: ./app/src/main/res/layout/user_list_row.xml
2    <?xml version="1.0" encoding="utf-8"?>
3    <LinearLayout
4      android:orientation="horizontal"
5      xmlns:android="http://schemas.android.com/apk/res/android"
6      xmlns:app="http://schemas.android.com/apk/res-auto"
7      xmlns:tools="http://schemas.android.com/tools"
8      android:layout_width="match_parent"
9      android:layout_height="wrap_content"
10      android:layout_margin="20dp"
11      android:gravity="center"
12      tools:context=".LoginActivity">
13    
14      <ImageView
15        android:id="@+id/presenceImageView"
16        android:layout_width="15dp"
17        android:layout_height="15dp"
18        app:srcCompat="@drawable/presence_icon" />
19    
20      <TextView
21        android:layout_width="match_parent"
22        android:layout_height="wrap_content"
23        tools:text="Neo"
24        android:textSize="20sp"
25        android:layout_marginStart="10dp"
26        android:id="@+id/usernameTextView"
27        app:layout_constraintTop_toBottomOf="@+id/editTextUsername"
28        />
29    
30    </LinearLayout>

This layout is the visual representation of how each item on the layout will look like. The layout has an image view that shows the users online status. The layout also has a textview that shows the name of the contact beside the icon. The icons are vector drawables. Let’s create the files.

Create a new drawable named presence_icon_online and paste this:

1// File: ./app/src/main/res/drawable/presence_icon_online.xml
2    <vector android:height="24dp" android:tint="#3FFC3C"
3        android:viewportHeight="24.0" android:viewportWidth="24.0"
4        android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
5        <path android:fillColor="#FF000000" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
6    </vector>

Create another drawable named presence_icon and paste this:

1// File: ./app/src/main/res/drawable/presence_icon.xml
2    <vector android:height="24dp" android:tint="#C0C0C6"
3        android:viewportHeight="24.0" android:viewportWidth="24.0"
4        android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
5        <path android:fillColor="#FF000000" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
6    </vector>

Next, open the ContactListActivity class and paste this:

1// File: ./app/src/main/java/com/example/messengerapp/ContactListActivity.kt
2    import android.content.Intent
3    import android.os.Bundle
4    import android.support.v7.app.AppCompatActivity
5    import android.support.v7.widget.LinearLayoutManager
6    import android.util.Log
7    import com.pusher.client.Pusher
8    import com.pusher.client.PusherOptions
9    import com.pusher.client.channel.PresenceChannelEventListener
10    import com.pusher.client.channel.User
11    import com.pusher.client.util.HttpAuthorizer
12    import kotlinx.android.synthetic.main.activity_contact_list.*
13    import retrofit2.Call
14    import retrofit2.Callback
15    import retrofit2.Response
16    
17    class ContactListActivity : AppCompatActivity(),
18        ContactRecyclerAdapter.UserClickListener {
19    
20      private val mAdapter = ContactRecyclerAdapter(ArrayList(), this)
21      
22      override fun onCreate(savedInstanceState: Bundle?) {
23        super.onCreate(savedInstanceState)
24        setContentView(R.layout.activity_contact_list)
25        setupRecyclerView()
26        fetchUsers()
27        subscribeToChannel()
28      }
29    
30    }

In this class, we initialized the ContactRecyclerAdapter, then called three functions in the onCreate method. Let’s create these new functions.

In the same class, add the following methods:

1private fun setupRecyclerView() {
2      with(recyclerViewUserList) {
3        layoutManager = LinearLayoutManager(this@ContactListActivity)
4        adapter = mAdapter
5      }
6    }
7    
8    private fun fetchUsers() {
9      RetrofitInstance.retrofit.getUsers().enqueue(object : Callback<List<UserModel>> {
10        override fun onFailure(call: Call<List<UserModel>>?, t: Throwable?) {}
11        override fun onResponse(call: Call<List<UserModel>>?, response: Response<List<UserModel>>?) {
12          for (user in response!!.body()!!) {
13            if (user.id != Singleton.getInstance().currentUser.id) {
14              mAdapter.add(user)
15            }
16          }
17        }
18      })
19    }
20    
21    private fun subscribeToChannel() {
22    
23      val authorizer = HttpAuthorizer("http://10.0.2.2:5000/pusher/auth/presence")
24      val options = PusherOptions().setAuthorizer(authorizer)
25      options.setCluster("PUSHER_APP_CLUSTER")
26    
27      val pusher = Pusher("PUSHER_APP_KEY", options)
28      pusher.connect()
29    
30      pusher.subscribePresence("presence-channel", object : PresenceChannelEventListener {
31        override fun onUsersInformationReceived(p0: String?, users: MutableSet<User>?) {
32          for (user in users!!) {
33            if (user.id!=Singleton.getInstance().currentUser.id){
34              runOnUiThread {
35                mAdapter.showUserOnline(user.toUserModel())
36              }
37            }
38          }
39        }
40    
41        override fun onEvent(p0: String?, p1: String?, p2: String?) {}
42        override fun onAuthenticationFailure(p0: String?, p1: Exception?) {}
43        override fun onSubscriptionSucceeded(p0: String?) {}
44    
45        override fun userSubscribed(channelName: String, user: User) {
46          runOnUiThread {
47            mAdapter.showUserOnline(user.toUserModel())
48          }
49        }
50    
51        override fun userUnsubscribed(channelName: String, user: User) {
52          runOnUiThread {
53            mAdapter.showUserOffline(user.toUserModel())
54          }
55        }
56      })
57    }
58    
59    override fun onUserClicked(user: UserModel) {
60      val intent = Intent(this, ChatRoom::class.java)
61      intent.putExtra(ChatRoom.EXTRA_ID,user.id)
62      intent.putExtra(ChatRoom.EXTRA_NAME,user.name)
63      intent.putExtra(ChatRoom.EXTRA_COUNT,user.count)
64      startActivity(intent)
65    }

Replace the PUSHER_APP_* keys with the values on your dashboard.

  • setupRecyclerView assigns a layout manager and an adapter to the recycler view. For a recycler view to work, you need these two things.
  • fetchUsers fetches all the users from the server and displays on the list. It exempts the current user logged in.
  • subcribeToChannel subscribes to a presence channel. When you subscribe to one, the onUsersInformationReceived gives you all the users subscribed to the channel including the current user. So, in that callback, we call the showUserOnline method in the adapter class so that the icon beside the user can be changed to signify that the user is online.
  • onUserClicked is called when a contact is selected. We pass the details of the user to the next activity called ChatRoom.

In the previous snippet, we used an extension function to transform the User object we receive from Pusher to our own UserModel object. Let’s define this extension.

Create a new class called Utils and paste this:

1// File: ./app/src/main/java/com/example/messengerapp/Utils.kt
2    import com.pusher.client.channel.User
3    import org.json.JSONObject
4    
5    fun User.toUserModel():UserModel{
6      val jsonObject = JSONObject(this.info)
7      val name = jsonObject.getString("name")
8      val numb = jsonObject.getInt("count")
9      return UserModel(this.id, name, numb)
10    }

Now, since we referenced a ChatRoom activity earlier in the onUserClicked method, let’s create it.

Create a new activity called ChatRoom. The activity comes with a layout file activity_chat_room, paste this in the layout file:

1// File: ./app/src/main/res/layout/activity_chat_room.xml
2    <?xml version="1.0" encoding="utf-8"?>
3    <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
4      xmlns:app="http://schemas.android.com/apk/res-auto"
5      xmlns:tools="http://schemas.android.com/tools"
6      android:layout_width="match_parent"
7      android:layout_height="match_parent"
8      tools:context=".ChatRoom">
9    
10      <android.support.v7.widget.RecyclerView
11        android:id="@+id/recyclerViewChat"
12        android:layout_width="match_parent"
13        android:layout_height="match_parent" />
14    
15      <EditText
16        android:id="@+id/editText"
17        android:layout_width="0dp"
18        android:layout_height="wrap_content"
19        android:layout_margin="16dp"
20        android:hint="Enter a message"
21        app:layout_constraintBottom_toBottomOf="parent"
22        app:layout_constraintEnd_toStartOf="@+id/sendButton"
23        app:layout_constraintStart_toStartOf="parent" />
24    
25      <android.support.design.widget.FloatingActionButton
26        android:id="@+id/sendButton"
27        android:layout_width="wrap_content"
28        android:layout_height="wrap_content"
29        android:layout_gravity="end|bottom"
30        android:layout_margin="16dp"
31        android:src="@android:drawable/ic_menu_send"
32        app:layout_constraintEnd_toEndOf="parent"
33        app:layout_constraintBottom_toBottomOf="parent" />
34    
35    </android.support.constraint.ConstraintLayout>

The layout above contains a recycler view for the chat messages, an edit text to collect new messages, and a floating action button to send the message.

Next, create a new class called ChatRoomAdapter and paste the following:

1// File: ./app/src/main/java/com/example/messengerapp/ChatRoomAdapter.kt
2    import android.support.v7.widget.CardView
3    import android.support.v7.widget.RecyclerView
4    import android.view.LayoutInflater
5    import android.view.View
6    import android.view.ViewGroup
7    import android.widget.RelativeLayout
8    import android.widget.TextView
9    import java.util.*
10    
11    class ChatRoomAdapter (private var list: ArrayList<MessageModel>)
12      : RecyclerView.Adapter<ChatRoomAdapter.ViewHolder>() {
13    
14      override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
15        return ViewHolder(LayoutInflater.from(parent.context)
16            .inflate(R.layout.chat_item, parent, false))
17      }
18    
19      override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(list[position])
20    
21      override fun getItemCount(): Int = list.size
22    
23      fun add(message: MessageModel) {
24        list.add(message)
25        notifyDataSetChanged()
26      }
27    
28      inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
29        private val messageTextView: TextView = itemView.findViewById(R.id.text)
30        private val cardView: CardView = itemView.findViewById(R.id.cardView)
31    
32        fun bind(message: MessageModel) = with(itemView) {
33          messageTextView.text = message.message
34          val params = cardView.layoutParams as RelativeLayout.LayoutParams
35          if (message.senderId==Singleton.getInstance().currentUser.id) {
36            params.addRule(RelativeLayout.ALIGN_PARENT_RIGHT)
37          }
38        }
39      }
40    }

This adapter works in a similar fashion as the one we created earlier. One difference though is that the show online and offline methods are not needed here.

Next, create another class named MessageModel and paste this:

1// File: ./app/src/main/java/com/example/messengerapp/MessageModel.kt
2    data class MessageModel(val message: String, val senderId: String)

The chat_item layout used in the onCreateViewHolder method of the adapter class represents how each layout will look like. Create a new layout called chat_item and paste this:

1// File: ./app/src/main/res/layout/chat_item.xml
2    <?xml version="1.0" encoding="utf-8"?>
3    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
4      xmlns:app="http://schemas.android.com/apk/res-auto"
5      android:layout_width="wrap_content"
6      android:layout_height="wrap_content"
7      android:layout_margin="16dp"
8      android:orientation="vertical">
9    
10      <android.support.v7.widget.CardView
11        android:id="@+id/cardView"
12        android:layout_width="wrap_content"
13        android:layout_height="wrap_content"
14        android:layout_gravity="start"
15        app:cardCornerRadius="8dp"
16        app:cardUseCompatPadding="true">
17    
18        <LinearLayout
19          android:layout_width="wrap_content"
20          android:layout_height="wrap_content"
21          android:gravity="start"
22          android:orientation="vertical"
23          android:padding="8dp">
24    
25          <TextView
26            android:id="@+id/text"
27            android:layout_width="wrap_content"
28            android:layout_height="wrap_content"
29            android:layout_gravity="center_vertical|start"
30            android:layout_marginBottom="4dp"
31            android:textStyle="bold" />
32    
33        </LinearLayout>
34    
35      </android.support.v7.widget.CardView>
36    
37    </RelativeLayout>

Finally, open the ChatRoom activity class and paste this:

1// File: ./app/src/main/java/com/example/messengerapp/ChatRoom.kt
2    import android.app.Activity
3    import android.os.Bundle
4    import android.support.v7.app.AppCompatActivity
5    import android.support.v7.widget.LinearLayoutManager
6    import android.util.Log
7    import android.view.View
8    import android.view.inputmethod.InputMethodManager
9    import com.pusher.client.Pusher
10    import com.pusher.client.PusherOptions
11    import com.pusher.client.channel.PrivateChannelEventListener
12    import com.pusher.client.util.HttpAuthorizer
13    import kotlinx.android.synthetic.main.activity_chat_room.*
14    import okhttp3.MediaType
15    import okhttp3.RequestBody
16    import org.json.JSONObject
17    import retrofit2.Call
18    import retrofit2.Callback
19    import retrofit2.Response
20    import java.lang.Exception
21    import java.util.*
22    
23    class ChatRoom : AppCompatActivity() {
24    
25      companion object {
26        const val EXTRA_ID = "id"
27        const val EXTRA_NAME = "name"
28        const val EXTRA_COUNT = "numb"
29      }
30    
31      private lateinit var contactName: String
32      private lateinit var contactId: String
33      private var contactNumb: Int = -1
34      lateinit var nameOfChannel: String
35      val mAdapter = ChatRoomAdapter(ArrayList())
36    
37      override fun onCreate(savedInstanceState: Bundle?) {
38        super.onCreate(savedInstanceState)
39        setContentView(R.layout.activity_chat_room)
40        fetchExtras()
41        setupRecyclerView()
42        subscribeToChannel()
43        setupClickListener()
44      }
45    }

In this file, we declared constants used to send data to the activity through intents. We also initialized variables we will use later like the adapter the contact details. We then called some additional methods in the onCreate method. Let’s add them to the class.

Add the fetchExtras method defined below to the class. The method gets the extras sent from the chatroom activity.

1private fun fetchExtras() {
2      contactName = intent.extras.getString(ChatRoom.EXTRA_NAME)
3      contactId = intent.extras.getString(ChatRoom.EXTRA_ID)
4      contactNumb = intent.extras.getInt(ChatRoom.EXTRA_COUNT)
5    }

The next method is the setupRecyclerView method. This initializes the recycler view with an adapter and a layout manager. Paste the function in the same class as before:

1private fun setupRecyclerView() {
2      with(recyclerViewChat) {
3        layoutManager = LinearLayoutManager(this@ChatRoom)
4        adapter = mAdapter
5      }
6    }

The next method is the subscribeToChannel method. This method subscribes the user to a private channel with the selected contact. Paste the following code to the same class as before:

1private fun subscribeToChannel() {
2      val authorizer = HttpAuthorizer("http://10.0.2.2:5000/pusher/auth/private")
3      val options = PusherOptions().setAuthorizer(authorizer)
4      options.setCluster("PUSHER_APP_CLUSTER")
5    
6      val pusher = Pusher("PUSHER_APP_KEY", options)
7      pusher.connect()
8    
9      nameOfChannel = if (Singleton.getInstance().currentUser.count > contactNumb) {
10        "private-" + Singleton.getInstance().currentUser.id + "-" + contactId
11      } else {
12        "private-" + contactId + "-" + Singleton.getInstance().currentUser.id
13      }
14    
15      Log.i("ChatRoom", nameOfChannel)
16    
17      pusher.subscribePrivate(nameOfChannel, object : PrivateChannelEventListener {
18        override fun onEvent(channelName: String?, eventName: String?, data: String?) {
19          val obj = JSONObject(data)
20          val messageModel = MessageModel(obj.getString("message"), obj.getString("sender_id"))
21    
22          runOnUiThread {
23            mAdapter.add(messageModel)
24          }
25        }
26    
27        override fun onAuthenticationFailure(p0: String?, p1: Exception?) {}
28        override fun onSubscriptionSucceeded(p0: String?) {}
29      }, "new-message")
30    }

Replace the PUSHER_APP_* keys with the values on your dashboard.

The code above allows a user to subscribe to a private channel. A private channel requires authorization like the presence channel. However, it does not expose a callback that is triggered when other users subscribe.

Next method to be added is the setupClickListener. Paste the method to the same class as before:

1private fun setupClickListener() {
2      sendButton.setOnClickListener{
3        if (editText.text.isNotEmpty()) {
4          val jsonObject = JSONObject()
5          jsonObject.put("message",editText.text.toString())
6          jsonObject.put("channel_name",nameOfChannel)
7          jsonObject.put("sender_id",Singleton.getInstance().currentUser.id)
8          
9          val jsonBody = RequestBody.create(
10              MediaType.parse("application/json; charset=utf-8"), 
11              jsonObject.toString()
12          )
13    
14          RetrofitInstance.retrofit.sendMessage(jsonBody).enqueue(object: Callback<String>{
15            override fun onFailure(call: Call<String>?, t: Throwable?) {}
16            override fun onResponse(call: Call<String>?, response: Response<String>?) {}
17          })
18          
19          editText.text.clear()
20          hideKeyBoard()
21        }
22    
23      }
24    }

The method above assigns a click listener to the floating action button to send the message to the server. After the message is sent, we clear the text view and hide the keyboard.

Add a method to the same class for hiding the keyboard like this:

1private fun hideKeyBoard() {
2      val imm = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
3      var view = currentFocus
4      
5      if (view == null) {
6        view = View(this)
7      }
8      
9      imm.hideSoftInputFromWindow(view.windowToken, 0)
10    }

That’s all for the application. Now you can run your application in Android Studio and you should see the application in action.

Make sure the Node.js API we built earlier is running before running the Android application.

android-messenger-presence-demo

Conclusion

In this article, you have been introduced yet again to some Pusher’s capabilities such as the private and presence channel. We learned how to authenticate our users for the various channels. We used these channels to implement a private chat between two persons and an online notification for a contact.

The source code to the application built in this article is available on GitHub.