(Check out the MVVM Edition of this tutorial here)
Instant messaging is a feature used in a wide variety of apps. You might just be thinking social media apps, but it doesn’t stop there. E-commerce customer services, chatrooms, collaborative Slack or Discord-like apps, the list goes on.
I already did a post on building this same feature with Pusher Chatkit. Chatkit is a specialised SDK for chats and instant messaging, but that’s not what we’re using today.
We’re using Cloud Firestore. Firestore is a cloud database as a service, and not a chat SDK.
How are we using Firestore to build our chat messaging system? Firestore is a realtime database. Our code can listen to changes in the database and receive those updates on the device with almost no latency at all (provided you have an adequate internet connection).
That begs one last question before we get coding. Why do we want to use Cloud Firestore over Pusher Chatkit?
Advantages of Firestore to Chatkit
- Firestore can be written pretty much entirely with client-side code. Some features of Chatkit you’ll need to implement will require server side code.
- Every piece of data from Firestore has its own URL and can be easily downloaded in JSON format.
- Security is more flexible and potentially more secure as you can configure them yourself.
- Firestore belongs to the Firebase set of tools and services and thus, integrates strongly with them (like with Cloud Functions for example)
While not specialised to chat, Firestore has more than a few reasons to use over Chatkit. (Don’t get me wrong, Chatkit has its own set of advantages too. We’ll get to that another day).
Enough faffing about. Time to get coding!
Prerequisites
As with all things Firebase, you need to first connect your app to Firebase.
Then add these dependencies to your app/build.gradle
file.
implementation 'com.google.firebase:firebase-auth:16.0.5' implementation 'com.google.firebase:firebase-firestore:17.1.2' // For the RecyclerView implementation 'com.google.android.material:material:1.0.0'
As you might gather from the dependencies above, we’re using Firebase Authentication. We’ll be storing their user IDs in the database to help identify rooms and message owners.
How the App will work and Database Structure
The app will work like a chatroom. Once logged in, the user will be greeted with a screen where they can enter a room id. We’ll start with a rooms collection, using the room id as the document key.
Upon entering the room id, the user will be redirected to a RoomActivity where they can send messages for other users in the same room to see.
Inside each room document we’ll have a messages collection. Each message document has text, a timestamp, and the id of the user who sent it.
rooms > {roomid} > messages > {message document}
Lobby Page
Do note that I’m not using any architecture here to keep things easy to understand. I will also omit any activities not directly related to the Firestore part of this app such as the login page. If you want to see those, see the source code from Github at the bottom of the post.
class LobbyActivity : AppCompatActivity() { val auth = FirebaseAuth.getInstance() val user = auth.currentUser val firestore = FirebaseFirestore.getInstance() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_lobby) checkUser() setViewListeners() } private fun setViewListeners() { button_enter.setOnClickListener { enterRoom() } } private fun enterRoom() { button_enter.isEnabled = false val roomId = edittext_roomid.text.toString() if (roomId.isEmpty()) { showErrorMessage() return } firestore.collection("users").document(user!!.uid).collection("rooms") .document(roomId).set(mapOf( Pair("id", roomId) )) val intent = Intent(this, RoomActivity::class.java) intent.putExtra("INTENT_EXTRA_ROOMID", roomId) startActivity(intent) } private fun showErrorMessage() { textview_error_enter.visibility = View.VISIBLE button_enter.isEnabled = true } }
First we have our lobby activity where the user enters a room id. Upon entering, that user will be redirected to the RoomActivity passing the room id in the intent.
RoomActivity
class RoomActivity : AppCompatActivity() { val auth = FirebaseAuth.getInstance() val user = auth.currentUser val firestore = FirebaseFirestore.getInstance() val chatMessages = ArrayList<ChatMessage>() var chatRegistration: ListenerRegistration? = null var roomId: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_room) checkUser() initList() setViewListeners() } private fun setViewListeners() { button_send.setOnClickListener { sendChatMessage() } } private fun initList() { if (user == null) return list_chat.layoutManager = LinearLayoutManager(this) val adapter = ChatAdapter(chatMessages, user.uid) list_chat.adapter = adapter listenForChatMessages() } private fun listenForChatMessages() { roomId = intent.getStringExtra("INTENT_EXTRA_ROOMID") if (roomId == null) { finish() return } chatRegistration = firestore.collection("rooms") .document(roomId!!) .collection("messages") .addSnapshotListener { messageSnapshot, exception -> if (messageSnapshot == null || messageSnapshot.isEmpty) return@addSnapshotListener chatMessages.clear() for (messageDocument in messageSnapshot.documents) { chatMessages.add( ChatMessage( messageDocument["text"] as String, messageDocument["user"] as String, messageDocument["timestamp"] as Date )) } chatMessages.sortBy { it.timestamp } list_chat.adapter?.notifyDataSetChanged() } } private fun sendChatMessage() { val message = edittext_chat.text.toString() edittext_chat.setText("") firestore.collection("rooms").document(roomId!!).collection("messages") .add(mapOf( Pair("text", message), Pair("user", user?.uid), Pair("timestamp", FieldValue.serverTimestamp()) )) } override fun onDestroy() { chatRegistration?.remove() super.onDestroy() } }
This is where all the magic happens.
We listen to our messages collection to get realtime updates for new messages. Every time our list of messages updates, we sort that list by timestamp then pass it on to the recycler view adapter.
On the other side of things, we have our sendChatMessage function which simply adds a new document in the messages collection. Nothing else to see here really.
List item layout and Adapter
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:layout_marginStart="8dp" android:layout_marginEnd="8dp" xmlns:tools="http://schemas.android.com/tools"> <TextView android:id="@+id/textview_chat_sent" android:layout_width="wrap_content" android:layout_height="wrap_content" android:paddingTop="8dp" android:paddingBottom="8dp" android:paddingEnd="8dp" android:paddingStart="24dp" tools:text="This is a sent message keep the text going so we can see" android:background="@drawable/shape_bubble_sent" android:textColor="@android:color/white" android:maxWidth="256dp" /> <TextView android:id="@+id/textview_chat_received" android:layout_width="wrap_content" android:layout_height="wrap_content" android:paddingTop="8dp" android:paddingBottom="8dp" android:paddingStart="8dp" android:paddingEnd="24dp" android:layout_gravity="end" tools:text="This is a received message keep the text going so we can see" android:background="@drawable/shape_bubble_received" android:textColor="@android:color/black" android:maxWidth="256dp" /> </FrameLayout>
We want to account for chat bubbles that point to either the sender or receiver or a message. I decided to merge them into a single layout file and toggle the visibility of its components in the adapter.
class ChatAdapter(val chatMessages: List<ChatMessage>, val uid: String): RecyclerView.Adapter<ChatAdapter.ViewHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val inflater = LayoutInflater.from(parent.context) return ViewHolder(inflater, parent) } override fun getItemCount(): Int { return chatMessages.size } override fun onBindViewHolder(holder: ViewHolder, position: Int) { val chatMessage = chatMessages[position] if (chatMessage.user == uid) { holder.itemView.textview_chat_sent.text = chatMessage.text holder.itemView.textview_chat_received.visibility = View.GONE } else { holder.itemView.textview_chat_received.text = chatMessage.text holder.itemView.textview_chat_sent.visibility = View.GONE } } class ViewHolder(inflater: LayoutInflater, parent: ViewGroup): RecyclerView.ViewHolder( inflater.inflate(R.layout.list_item_chat, parent, false) ) { private var chatTextSent: TextView? = null private var chatTextReceived: TextView? = null init { chatTextSent = itemView.findViewById(R.id.textview_chat_sent) chatTextReceived = itemView.findViewById(R.id.textview_chat_received) } } }
The adapter is as simple as it gets. Set the text, then toggle the visibility of the components just mentioned.
And that about sums up the major components of the app.
Get the Source Code
As said quite a number of times in the post, the code snippets here only include the major components of the app, directly related to the Firestore-chat part of the app.
Click here if you want the full source code, as well as to see how I implemented authentication, navigation and such. It’s all the most bare-bones implementations, thus, should be easy for anyone to pick up.
Let me know if you like this post or if you would like me to do these tutorials with an MVVM architecture.