Building a Chat App With ChatKit for Android

...
Building a Chat App for Android with ChatKit

Did you know you can integrate Stream’s Android SDK with other open-source libraries? To show you just how easy it is, in this tutorial you’ll leverage data from Stream’s Chat API to power the messaging UI from ChatKit.

Specifically, this tutorial will cover:

  • Stream Setup
  • Implementing the ChatKit Interfaces
  • Building the Channels Screen
  • Building the Messaging Screen
  • Paging Data
  • Handling Input and Typing Events
  • Final Result

Understanding Stream’s Android UI Components Libraries

Before diving in, it may be helpful to get familiar with all of Stream’s Android component libraries.

Currently, Stream offers two sets of fully-featured, open-source UI components for Android messaging apps:

For more clarification on the Jetpack Compose and UI Components libraries, see Jetpack Compose vs. XML-based UI Components for Stream Chat.

However, Stream also offers lower-level libraries you can use on Android:

  • An Offline Support Library that provides caching, automatically retries failed calls, and offers observable data as Flows or LiveData objects.
  • A Low-level Chat Client that allows you to make API calls and subscribe to real-time chat events.

This tutorial will use ChatKit, an open-source chat UI library by Stfalcon, to build a basic messaging app on top of Stream's offline support library.

You'll be building on top of the offline library, observing the Chat state and displaying it using ChatKit.

implementation 'io.getstream:stream-chat-android-offline:4.17.2'
implementation 'com.github.stfalcon-studio:Chatkit:0.4.1'

Note: You can find the source code for this tutorial in the ChatKit Stream repository on GitHub.

Set Up Your Stream Account

For the purposes of this tutorial, it’s quicker and simpler to use the same hardcoded user defined in the Android Chat Messaging Tutorial.

This will allow you to avoid lots of extra steps, like creating user tokens, fetching your API key and Secret from your Stream dashboard, and creating sample data to populate your channels, among other things.

If you’d still like an account, you can sign up for a free Chat trial. Once you’re signed up, be sure to visit the Android Chat First Steps tutorial to learn how to use the Stream dashboard, create your first sample project, and more.

Implement the ChatKit Interfaces

ChatKit defines various interfaces that its Views can interact with, such as IDialog, IMessage, and IUser. To use ChatKit in your app, you need to create model objects that implement these interfaces.

The Stream Chat SDK already provides its own models for these types, so you’ll need to wrap these into the given interfaces. Let's take the example of just the Dialog (which is called a Channel in Stream's terminology):

class Dialog(
    val channel: io.getstream.chat.android.client.models.Channel
) : IDialog<Message> {
    override fun getId(): String = channel.id
    override fun getDialogPhoto(): String = channel.image
    override fun getDialogName(): String = channel.name
    override fun getUsers(): List<IUser> = channel.members.map { User(it.user) }
    override fun getLastMessage(): Message? = channel.messages.lastOrNull()?.let { Message(it) }
    override fun setLastMessage(message: Message?) { throw UnsupportedOperationException() }
    override fun getUnreadCount(): Int = channel.unreadCount ?: 0
}

This implementation of IDialog takes a Stream Channel object as its parameter and then reads the corresponding properties of the Channel as needed.

It also references implementations of the other two aforementioned ChatKit interfaces, which are similar simple wrappers:

class Message(
    private val message: io.getstream.chat.android.client.models.Message
) : IMessage {
    override fun getId(): String = message.id
    override fun getText(): String = message.text
    override fun getUser(): IUser = User(message.user)
    override fun getCreatedAt(): Date? = message.createdAt ?: message.createdLocallyAt
}

class User(
    private val user: io.getstream.chat.android.client.models.User
) : IUser {
    override fun getId(): String = user.id
    override fun getName(): String = user.name
    override fun getAvatar(): String = user.image
}

Other than these models, ChatKit also needs an ImageLoader implementation so it can display user avatars.

Use the Coil library to provide this capability:

implementation 'io.coil-kt:coil:1.3.2'

The interface is straightforward to implement with Coil:

object CoilImageLoader : ImageLoader {
    override fun loadImage(imageView: ImageView, url: String?, payload: Any?) {
        imageView.load(url) {
            allowHardware(false)
        }
    }
}

With the models and image loader prepared, you’re ready to build a Channel List screen.

Build a Channels List Screen

Start with the XML layout, which will contain a full-screen DialogsList from ChatKit:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.stfalcon.chatkit.dialogs.DialogsList
        android:id="@+id/dialogsList"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

The DialogsList works with an adapter that will provide it the data that it should display (just like a RecyclerView). You can create this adapter as a property in your MainActivity, and then connect it to the view in an onCreate function:

class MainActivity : AppCompatActivity() {
    private val adapter = DialogsListAdapter<Dialog>(CoilImageLoader)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.dialogsList.setAdapter(adapter)
    }
}

Note: The adapter receives your CoilImageLoader implementation as a parameter.

Initializing Stream Chat

The next step is to initializethe Stream Chat SDK by instantiating the ChatClient and ChatDomain classes. To do so, you’ll need your app’s API Key and Secret.

For more information about these steps, see the Android In-App Messaging Tutorial. You’ll use the same hardcoded demo user as the tutorial.

val client = ChatClient.Builder("b67pax5b2wdq", applicationContext)
    .logLevel(ChatLogLevel.ALL)
    .build()
ChatDomain.Builder(client, applicationContext).build()

val user = User(
    id = "tutorial-droid",
    extraData = mutableMapOf(
        "name" to "Tutorial Droid",
        "image" to "https://bit.ly/2TIt8NR",
    ),
)
client.connectUser(
    user = user,
    token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidHV0b3JpYWwtZHJvaWQifQ.NhEr0hP9W9nwqV7ZkdShxvi02C5PR7SJE7Cs4y7kyqg"
).enqueue {
    loadChannels(user)
}

With the connection ready, you can load your list of channels with the loadChannels method:

private fun loadChannels(user: User) {
    val filter: FilterObject = Filters.and( // 1
        Filters.eq("type", "messaging"),
        Filters.`in`("members", user.id),
    )
    val sort: QuerySort<Channel> = QuerySort.desc("last_updated") // 2
    ChatDomain.instance()
        .queryChannels(filter, sort)
        .enqueue { result ->
            val controller: QueryChannelsController = result.data() // 3
            controller.channels.observe(this) { channels -> 
                adapter.setItems(channels.map { Dialog(it) }) // 4
            }
        }
}

In the above code snippet, you:

  1. Created a filter to get channels that are of the messaging type where your current user is a member.
  2. Sorted these channels by last_updated so that channels with the newest messages show at the top. Thanks to the observable nature of the offline library, channels will update in real-time.
  3. Implemented QueryChannelsController from the offline library, which observes the current channel state. This state is automatically updated based on real-time WebSocket events as users send messages.
  4. Map new values to the Dialog instance and pass that list to the DialogsListAdapter whenever possible.

Now, you can build and run the application to see the list of loaded channels!

ChatKit demo channels list screen

For comparison, here’s how the same screen looks when implemented with Stream’s UI Components:

Stream Chat channels list comparison screen

Building the Messaging Screen

Next, you’ll create a new Empty Activity called MessagesActivity. For the XML layout, you’ll use a MessageList with a MessageInput below it:

<androidx.constraintlayout.widget.ConstraintLayout 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:background="@color/white">

    <com.stfalcon.chatkit.messages.MessagesList
        android:id="@+id/messagesList"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@+id/input"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.stfalcon.chatkit.messages.MessageInput
        android:id="@+id/input"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:inputHint="Send a message"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

This new screen will need to receive the channel's ID as a parameter, which you'll pass through Intent extras. It'll also use an adapter to display the list of messages, which you'll connect to MessagesList:

class MessagesActivity : AppCompatActivity() {
    private lateinit var messagesAdapter: MessagesListAdapter<Message>
    private lateinit var cid: String

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val binding = ActivityMessagesBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val currentUserId = ChatClient.instance().getCurrentUser()!!.id
        messagesAdapter = MessagesListAdapter<Message>(currentUserId, CoilImageLoader)
        binding.messagesList.setAdapter(messagesAdapter)

        cid = checkNotNull(intent.getStringExtra(CID_KEY)) {
            "Specifying a channel id is required when starting MessagesActivity"
        }
    }

    companion object {
        private const val CID_KEY = "key:cid"

        fun newIntent(context: Context, channel: Channel): Intent =
            Intent(context, MessagesActivity::class.java).putExtra(CID_KEY, channel.cid)
    }
}

With the cid extracted from the Intent, you can now use ChatDomain again, this time to grab a ChannelController that’ll provide you real-time observable data for a single channel:

ChatDomain.instance().watchChannel(cid, 30).enqueue { result ->
    val channelController = result.data()
    channelController.messages.observe(this) { messages ->
        messagesAdapter.clear(true) // 1
        messagesAdapter.addToEnd(messages.map { Message(it) }, true) // 2
    }
}

In the above snippet you:

  1. Clear messagesAdapter as you receive the entire list of messages when the observable data updates.
  2. Wrap all of your Stream message objects into the Message model (created earlier) that implements ChatKit’s IMessage interface.
    • You pass in true to reverse the order of messages here so that they display correctly in the list.

Paging Data

The code above sets you up for the initial load, where you load 30 messages for the current channel. However, as you scroll up, you need to load older messages.

You can load older messages by setting a listener on the messagesAdapter:

messagesAdapter.setLoadMoreListener(this)

To make this simple, you’ll make your MessagesActivity implement the OnLoadMoreListener interface that’s expected here.

To load more messages, you’ll use the loadOlderMessages method of ChatDomain to load 30 more messages when the listener is triggered:

class MessagesActivity : AppCompatActivity(), MessagesListAdapter.OnLoadMoreListener {
    override fun onLoadMore(page: Int, totalItemsCount: Int) {
        ChatDomain.instance().loadOlderMessages(cid, 30).enqueue()
    }
}

Handling Input and Typing Events

You have the code to load messages and to load even more when you scroll up. Now, you can set up the MessageInput so that you can also send messages of your own.

Add two listeners in the onCreate method:

binding.input.setInputListener(this)
binding.input.setTypingListener(this)

And again, you’ll implement these with MessagesActivity:

class MessagesActivity : AppCompatActivity(),
    MessagesListAdapter.OnLoadMoreListener,
    MessageInput.InputListener,
    MessageInput.TypingListener {

    override fun onSubmit(input: CharSequence?): Boolean {
        if (input.isNullOrEmpty()) return false

        ChatDomain.instance().sendMessage(
            io.getstream.chat.android.client.models.Message(
                cid = cid,
                text = input.toString(),
            )
        ).enqueue()
        return true
    }

    override fun onStartTyping() {
        ChatDomain.instance().keystroke(cid, null).enqueue()
    }

    override fun onStopTyping() {
        ChatDomain.instance().stopTyping(cid).enqueue()
    }
}

The typing listener is very straightforward, as ChatDomain has the exact methods you need to call into.

When sending a new message, you perform basic input validation:

  • You check if there’s anything typed into the input field.
  • If there is, you create a Stream Message and send it using ChatDomain.

Final Result

Lastly, you need to set up navigation between screens.

To add screen navigation, add the following code in MainActivity:

adapter.setOnDialogClickListener { dialog ->
    startActivity(MessagesActivity.newIntent(this, dialog.channel))
}

If you build and run the app again, you’ll have a functional, basic chat app:

  • You have a list of available channels
  • You can open any of these channels
  • You can read messages in these channels
  • You can send your own messages as well!
ChatKit demo app

For comparison, below is a messaging screen built using Stream’s UI Components (check out the library in the Chat UI Components sample app):

Stream Chat demo app messaging screen

Wrapping Up

That’s it! You can find the completed project’s source code on GitHub.

To sum up, here’s what you’ve implemented in this sample app… :

  • Channel list
  • Real-time, visible channel updates (try sending a message from a second device to test it out)
  • Unread counts in the channel list
  • Avatar loading on both screens
  • Displaying and sending plain text messages
  • Message pagination (older content loads as you scroll up)

… and here are some possible improvements you could work on from here:

  • Channel list pagination
  • Placeholder images for channels with no set image URL
  • Rich messaging (images and other attachments)
  • Error handling for Stream API calls (this tutorial skips this step for simplicity)

If you’d like to try Stream’s own UI implementations, see the Jetpack Compose Chat Tutorial or the Android Chat Tutorial.

As always, happy coding!