Instant messaging is undoubtedly one of the most widely used features of any app that users all across the world make use of. When I make a mention of instant messaging, what probably comes immediately to mind is Facebook Messenger, Snapchat, or even the one variant of the million SMS apps and bootlegs that happens to be installed in your phone.
But instant messaging goes just a little bit further, as it’s deployed not only in one-to-one conversational apps, or not even just in chatrooms and collaborative systems like Slack and Discord, but even in the world of customer services when we think about livechat, chatbots and the like.
For whichever use case you have, instant messaging is a feature that many developers should be able to deploy at a good rate, whatever the use case may be. I’m going to show you how to do exactly that with Pusher Chatkit.
Why Pusher Chatkit?
There are quite a few instant messaging tools out there that can be easily deployed in Android. Other ones include Sendbird, and even Firebase (through the use of Firestore and Firebase Cloud Messaging).
I chose Pusher Chatkit purely for its ease of use, and its flexibility to integrate with other services like Dialogflow for example if you wanted to make a chatbot.
A little introduction to Pusher Chatkit
Pusher is a group with a strong reputation in building realtime developer APIs. Pusher is their API for realtime in-app chat and includes SDKs for Android, iOS, JavaScript, and even React, and that’s just their front-end SDKs. They also have SDKs for the back-end, as well as an API interface.
Chatkit works through users joining a room with a given ID, and being able to subscribe to those rooms where they can receive existing messages in that room, as well as any new ones almost immediately after they are sent.
It has a strong reputation and is used by many big apps and companies such as Monzo, Duolingo, and even Github, just to name a few.
Other Chatkit features include Push Notifications, Webhooks, and Indicators for read, typing, and online presence.
More information can be found on their official website.
The App we’ll be making
This is a simple app that contains 2 activities. The LobbyActivity simply has a text field where we enter a room number to join. The MainActivity is basically the chatroom where the user subscribes to receive messages from Chatkit, and also where the user can send messages.
The app will be using an MVVM architecture and of course, I’ll be making use of LiveData rather than RxJava here. As with most things, a production app in this case would be better made with dependency injection (Dagger), but for the sake of simplicity of simplicity, I’m not using it for this tutorial.
Accessing the Console
Chatkit’s developer area provides us with some very useful information including an Analytics dashboard, and a console where we can view our users, rooms, and messages which will help us as we go along.
It’s also worth noting that for security purposes, there isn’t a straightforward way the Android SDK allows us to create or delete users. You don’t want normally want to give your users such power. These operations are normally done on the backend and therefore, are available on the backend SDKs of Chatkit. It is possible to do it via the API interface, but for this tutorial, we’ll be creating our users manually in the console.
So go to the Pusher Console and create a new user with an ID and name of your choosing. Take a note of the ID. You’ll need that later.
Also go to the Credentials tab of the console and make a note of the Instance Locator, and the Token Endpoint. You’ll need these in your app.
Import the Dependencies
Finally, we go into the code. Add these dependencies to your app/build.gradle file.
implementation 'com.pusher:pusher-java-client:2.0.1' implementation 'com.pusher:chatkit-android:1.8.1' implementation 'androidx.recyclerview:recyclerview:1.0.0' implementation 'android.arch.lifecycle:extensions:1.1.1'
As you can tell, on top of the Pusher Chatkit dependencies, we’re also importing RecyclerView and Lifecycle Extensions.
Creating the Repository
Create a Repository class where you handle all your calls to Chatkit. In this class, create a few MutableLiveDatas, then get an instance of ChatManager.
class ChatRepository(context: Context, val roomId: String?) { constructor(context: Context): this(context, null) var room = MutableLiveData<Room>() var chatkitUser = MutableLiveData<CurrentUser>() var messages = MutableLiveData<List<Message>>(emptyList()) private val chatManager = ChatManager( instanceLocator = ApiConstants.CHATKIT_INSTANCE_LOCATOR, userId = "john, dependencies = AndroidChatkitDependencies( tokenProvider = ChatkitTokenProvider( endpoint = ApiConstants.CHATKIT_TOKEN_ENDPOINT, userId = "john" ), context = context ))
The class has two constructors. In the LobbyActivitty, we’ll call the constructor without the RoomId because this activity will handle joining or creating the rooms. The RoomId will only be needed when we send and receive messages in the MainActivity.
In the userId fields here, I just hard-coded “john” which I defined earlier in the console to keep this tutorial simple, otherwise this could get easily convoluted.
I’m going to mention now that all of these functions are going to make asynchronous calls, but if you want to make things synchronous instead, just create an instance of SynchronousChatManager instead of the standard ChatManager.
I made the mistake of building the whole Repository with the regular asynchronous ChatManager before I found out about the SynchronousChatManager. I would have preferred the synchronous approach as that would have allowed me to delegate the logic to the ViewModel, rather than handling it in the Repository.
We have 3 pieces of LiveData we need to populate: ChatkitUser, Room, and Messages. Let’s go about fetching their respective data, one by one.
First let’s start with the ChatkitUser.
private fun connectChatkit() { chatManager.connect { result -> when (result) { is Result.Success -> { this.chatkitUser.postValue(result.value) if (roomId != null) { subscribeToRoom(result.value) } } is Result.Failure -> handleError(result.error) } } }
We get the user by simply calling chatManager.connect(). When this method succeeds, we can post that value to the LiveData then subscribe to our room to get our messages, but before we do that, let’s create rooms first.
Joining/Creating Rooms
We have a few situations here we need to work around. We can’t join a room that doesn’t exist, nor can we create a room that’s already been created.
fun attemptJoinRoom(roomId: String) { this.chatkitUser.value?.getJoinableRooms { result -> when (result) { is Result.Success -> { for (joinableRoom in result.value) if (joinableRoom.id == roomId) { joinRoom(roomId) return@getJoinableRooms } // If room doesn't exist, create it instead createRoom(roomId) } is Result.Failure -> handleError(result.error) } } }
The workaround is we can get a list of the rooms a user can join and use simple logic to see if our constructor’s roomId belongs to that list.
fun joinRoom(roomId: String) { this.chatkitUser.value?.joinRoom( roomId ) { result -> when (result) { is Result.Success -> this.room.postValue(result.value) is Result.Failure -> handleError(result.error) } } } fun createRoom(roomId: String) { this.chatkitUser.value?.createRoom( roomId, roomId, null, false, null ) { result -> when (result) { is Result.Success -> joinRoom(roomId) is Result.Failure -> handleError(result.error) } } } fun leaveRoom() { this.chatkitUser.value?.leaveRoom( roomId!! ) { result -> when (result) { is Result.Success -> room.postValue(null) is Result.Failure -> handleError(result.error) } } }
Finally, we have our 3 functions for Creating, Joining, and Leaving rooms. Each one simply updates our room LiveData accordingly. We can let the ViewModel and Observers handle the rest.
Sending / Receiving Messages
Ok the room stuff was a bit long, but thankfully, sending and messages is a lot simpler.
private fun subscribeToRoom(user: CurrentUser) { user.subscribeToRoomMultipart( this.roomId!!, RoomListeners(onMultipartMessage = { message -> this.messages.postValue(Helper.addListElement(this.messages.value!!, message)) }), 20 ) {} } fun sendMessage(message: String) { this.chatkitUser.value?.sendSimpleMessage( this.roomId!!, message ) { result -> when (result) { is Result.Failure -> handleError(result.error) } } }
Recall, we call subscribeToRoom() when we successfully connect to our ChatManager. Subscribing to a room will give us a previous a number of messages in the room, and every message thereafter almost immediately after Chatkit receives it which we’ll be using to populate our LiveData. That 20 parameter is that number of messages we’ll receive.
SendMessage() is also as straightforward as it gets. After it sends the message, we don’t necessarily need to do anything if it succeeds. The message will be received by Chatkit which will send it back downstream to our room subscription.
fun <T: Any> addListElement(list: List<T>, element: T): List<T> { val arrayList = ArrayList(list) arrayList.add(element) return arrayList.toList() }
If you wanted it, here’s the Helper function I’m using to add elements to the list. I like keeping my Kotlin safe and keeping my LiveData immutable after it leaves the repository (despite my excessive use of !! operators here. I’m working on it).
Terminating Chatkit
fun terminate() { chatManager.close{} }
When the ChatRepository is no longer used, we have to close ChatManager as well to prevent data leaks and manage garbage and all that. We’ll tie this function to the ViewModel’s own termination function.
Creating the Lobby
As mentioned above, we’ll have 2 activities so that means 2 ViewModels as well. Let’s start with the Lobby and its ViewModel.
LobbyViewModel
class LobbyViewModel: ViewModel() { private lateinit var chatRepository: ChatRepository fun initChatbotRepository(context: Context) { chatRepository = ChatRepository(context) } fun getRoom(): LiveData<Room> { return chatRepository.room } fun joinRoom(roomId: String) { chatRepository.attemptJoinRoom(roomId) } override fun onCleared() { super.onCleared() if (this::chatRepository.isInitialized) chatRepository.terminate() } }
I’m keeping things simple here. The LobbyActivity is only concerned with joining rooms, so we have our joinRoom() function to respond to a button click, getRoom() to return LiveData from the Repository, and the termination function for the ChatManager.
I decided to pass Context into the single method of initChatbotRepository() instead of the ViewModel’s constructor because I didn’t want to tie the context to the ViewModel if we were only using it for one function.
LobbyActivity
Let’s start with some copy-pastable XML code.
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" tools:context=".activities.lobby.LobbyActivity"> <data> <variable name="lobbyViewModel" type="com.example.pusherbot.activities.lobby.LobbyViewModel" /> </data> <RelativeLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/colorPrimary"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="32dp" android:layout_centerInParent="true"> <EditText android:id="@+id/input_roomid" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/white_with_border" android:textSize="20sp" android:cursorVisible="false" android:padding="16dp" android:inputType="number" android:textStyle="bold" android:hint="Room Id" android:gravity="center_horizontal"/> <Button android:id="@+id/button_join" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:layout_marginTop="32dp" android:paddingLeft="64dp" android:paddingRight="64dp" android:text="Join" android:textColor="#FFFFFF" android:backgroundTint="#388E3C" /> </LinearLayout> </RelativeLayout> </layout>
Other than the ViewModel binding here, all you have to know about this layout is the edit text field for the RoomId, and the Button to kick things off, verify the roomId, and start an intent to the MainActivity.
As for the Kotlin code, here are the important functions.
private fun observeRoom() { viewModel.getRoom().observe(this, Observer { room -> if (room != null) { handleRoomJoined(room.id) } }) } private fun handleSendButton() { viewModel.joinRoom(input_roomid.text.toString()) } private fun handleRoomJoined(roomId: String) { val preferences = getSharedPreferences("MAIN_PREFERENCES", Context.MODE_PRIVATE) preferences.edit() .putString("ROOM_ID", roomId) .apply() launchChatroom(roomId) } private fun launchChatroom(roomId: String) { val intent = Intent(this, MainActivity::class.java) intent.putExtra("ROOM_ID", roomId) startActivity(intent) finish() }
When the Send Button is clicked, the task is delegated to the ViewModel to verify the room and either join or create it. Once either operation completes, observeRoom() will receive the room from the LiveData, save it to preferences for later use, then launch the MainActivity, otherwise known as the chatroom. That about wraps it up for the LobbyActivity.
I only included the key functions here related to our Chatkit data flow, but if you want the full code, I linked the Github repository for this project below.
MainActivity (Chatroom)
Again, let’s start with the ViewModel.
MainViewModel
class MainViewModel: BaseViewModel() { private lateinit var chatRepository: ChatRepository fun initChatbotRepository(context: Context, roomId: String) { chatRepository = ChatRepository(context, roomId) } fun getRoom(): LiveData<Room> { return chatRepository.room } fun getMessages(): LiveData<List<Message>> { return chatRepository.messages } fun sendMessage(message: String) { chatRepository.sendMessage(message) } fun leaveRoom() { chatRepository.leaveRoom() } override fun onCleared() { super.onCleared() if (this::chatRepository.isInitialized) chatRepository.terminate() } }
The ViewModel here simply establishes the connection between the View and the Repository. Again, I would have ideally included more logic here by letting the ViewModel handle the results of a SynchronousChatManager.
MainActivity
Once again, some copy-pastable XML code.
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" tools:context=".activities.main.MainActivity"> <data> <variable name="mainViewModel" type="com.example.pusherbot.activities.main.MainViewModel" /> </data> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="#FAF3F3" android:orientation="vertical" > <androidx.recyclerview.widget.RecyclerView android:id="@+id/chat_messages_list" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" /> <LinearLayout android:layout_width="match_parent" android:layout_height="60dp" android:background="#FFFFFF" android:orientation="horizontal" android:layout_gravity="bottom"> <EditText android:id="@+id/chat_input" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:hint="Send message..." android:paddingStart="16dp" android:background="@android:color/transparent" android:layout_gravity="center_vertical" android:textSize="18sp" /> <ImageButton android:id="@+id/chat_send_button" android:background="@android:color/transparent" android:layout_width="40dp" android:layout_height="40dp" android:scaleType="centerInside" android:src="@drawable/ic_send_black" android:tint="@color/colorPrimary" android:layout_marginEnd="16dp" android:padding="4dp" android:layout_gravity="center_vertical" /> </LinearLayout> </LinearLayout> </layout>
The two components you need to know about here are the RecyclerView of messages in the chat, and a layout of an EditText and Button for sending messages.
Speaking of the RecyclerView, here’s the adapter I’m using.
class ChatAdapter(private val messages: List<Message>): RecyclerView.Adapter<ChatAdapter.ViewHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { return ViewHolder(LayoutInflater.from(parent.context) .inflate(R.layout.list_item_chat, parent, false)) } override fun getItemCount(): Int { return messages.size } override fun onBindViewHolder(holder: ViewHolder, position: Int) { val message = this.messages[position] holder.sender.text = message.sender.name when(val data = message.parts[0].payload) { is Payload.Inline -> holder.message.text = data.content } } class ViewHolder(view: View): RecyclerView.ViewHolder(view) { val sender = view.chat_sender val message = view.chat_message } }
And the list item layout:
<?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:orientation="vertical" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp"> <TextView android:id="@+id/chat_sender" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="16sp" android:textColor="#1A1A1A" android:textStyle="bold" tools:text="John" /> <TextView android:id="@+id/chat_message" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="14sp" android:layout_marginTop="8dp" android:textColor="#1A1A1A" tools:text="Hello World" /> </LinearLayout>
Now finally, the Activity Kotlin code.
private fun initRoom() { val roomId = intent.getStringExtra("ROOM_ID") if (roomId == null) { handleNullRoomId() return } supportActionBar?.title = "Room $roomId" viewModel.initChatbotRepository(this, roomId) } private fun observeRoom() { viewModel.getRoom().observe(this, Observer { room -> if (room == null) handleLeaveRoom() }) } private fun observeMessages() { viewModel.getMessages().observe(this, Observer { messages -> handleMessages(messages) }) } private fun handleMessages(messages: List<Message>) { val chatAdapter = ChatAdapter(messages) chat_messages_list.layoutManager = LinearLayoutManager(this) chat_messages_list.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) chat_messages_list.adapter = chatAdapter chat_messages_list.layoutManager?.scrollToPosition(messages.size - 1) } private fun handleSendButton() { sendMessage(chat_input.text.toString()) chat_input.setText("") } private fun sendMessage(message: String) { viewModel.sendMessage(message) } private fun handleLeaveRoom() { val preferences = getSharedPreferences("MAIN_PREFERENCES", Context.MODE_PRIVATE) preferences.edit() .putString("ROOM_ID", null) .apply() startActivity(Intent(this, LobbyActivity::class.java)) finish() } private fun handleNullRoomId() { startActivity(Intent(this, LobbyActivity::class.java)) finish() }
This is a bit more involved so I’ll explain what’s going on here.
initRoom() just starts the ChatbotRepository, and includes a safety check. We don’t want a null RoomId in this activity. If that situation arises, we can simply send the user back to the LobbyActivity.
We then have our LiveData observers. observeRoom() is used just for checking if the user left the room, like through the options menu. observeMessages() of course receives the messages of the chat, then passes them onto the adapter.
I think the rest is pretty self-explanatory.
Get the Sample on Github
All the code here can be found on Github. It’s a bit more involved as it uses a LoginActivity with Firebase Auth to get a unique userID for each user, but it’s not fully implemented so you can ignore most of that for now. I’ll get back to it once I manage to successfully make use of Chatkit’s API interface to create users. For now, we can hardcode John. I’m sure he’s fine with that.