Using the PubNub Kotlin Chat SDK
PubNub's Kotlin Chat SDK is the recommended way for Android developers to create new chat applications or add in-app chat to existing applications
Chat SDK Tutorial: Environment Setup for Kotlin
This tutorial walks you through a basic Android sample app created with PubNub Kotlin Chat SDK 1.0.0. It explains how the sample is structured, how the app initializes chat, and how the messaging features map to the Kotlin Chat SDK APIs. The sample app is available at https://github.com/PubNubDevelopers/Chat-SDK-Tutorial-Kotlin and is released under MIT. The sample is designed for learning and has minimal error handling.
Note: This written tutorial and the repository have been refreshed for PubNub Kotlin Chat SDK 1.0.0. Some video footage and screenshots may show an earlier version of the UI, but the code snippets below match the upgraded sample.
Prerequisites
The Kotlin Chat SDK is UI and framework-agnostic, so you control how your application looks and how it is architected. The sample application uses Jetpack Compose with a small custom UI.
Make sure you have the following installed on your computer:
Download the Source Application
In a terminal or command window, navigate to an empty folder. Enter the following command to clone the repository.
git clone https://github.com/PubNubDevelopers/Chat-SDK-Tutorial-Kotlin.gitInstall dependencies
Open the Chat-SDK-Tutorial-Kotlin folder in Android Studio. Gradle will download the required Android, Compose, and PubNub dependencies. If you experience build issues with a newer Android Studio version, contact the DevRel team at devrel@pubnub.com
Open the following file:
/app/src/main/java/com/pubnub/chatsdk/kotlintutorial/Keys.kt
The file contains placeholders for your PubNub publish and subscribe keys:
package com.pubnub.chatsdk.kotlintutorial
// REPLACE THESE WITH YOUR PUB / SUB KEYS
val PUBNUB_PUBLISH_KEY="PUBLISH_KEY"
val PUBNUB_SUBSCRIBE_KEY="SUBSCRIBE_KEY"The next step is to create your own publish and subscribe keys to replace these placeholders.
Create keys
Demo keys
You're ready to go!
We have assigned demo keys that are set up to support the features of this tutorial app. These keys will be active for 72 hours, but you can sign up for a free account at the end of the tutorial to create permanent keys for your own app.
The demo keys have the following functionality activated:
- Presence This lets the app show whether users are online and powers presence-aware UI.
- Message Persistence This stores message history so the app can load earlier messages and reactions when a user reopens a channel.
- App Context This stores user, channel, and membership metadata used by the sample app.
My own keys
- You'll need to create an account to use your own keysSign in to your PubNub Dashboard.
- Go to Keysets using the left hand side navigation.
- Click the “Create New Keyset” button on the upper right hand-side of the portal.
- A pop-up appears. Give the keyset a name and click the “Create” button.
- Click on the newly created keyset.
- Enable three features for the keyset: Presence, Message Persistence, and App Context.
- Enable Presence by clicking on the toggle to turn it on. A pop-up will require that you enter "ENABLE". Enter "ENABLE" in all caps and press the "Enable" button. You can leave the default settings.
- Enable Message Persistence by clicking on the toggle to turn it on. You can leave the default settings.
- Enable App Context by clicking on the toggle to turn it on if it is not already enabled. Select the region closest to you to store your context.
- Click on the “Save Changes” button in the bottom right corner of the portal. A confirmation message will appear in the upper-left corner of the portal confirming that these changes have been saved.
- Scroll back to the top of the portal. Copy the Publish key by clicking on the copy icon and paste it into your app.
- Copy the Subscribe key by clicking on the copy icon and paste it into your app.
Build and run
Watch the video or follow the steps below to build and run your application.
Add PubNub Keys
In the previous parts of this tutorial, you cloned the sample application, loaded it into Android Studio, installed dependencies, and created PubNub keys. Locate the file /app/src/main/java/com/pubnub/chatsdk/kotlintutorial/Keys.kt and replace the placeholder publish and subscribe keys with the keys you generated in the previous step.
package com.pubnub.chatsdk.kotlintutorial
// REPLACE THESE WITH YOUR PUB / SUB KEYS
val PUBNUB_PUBLISH_KEY="PUBLISH_KEY"
val PUBNUB_SUBSCRIBE_KEY="SUBSCRIBE_KEY"Run the Application
Everything is now configured to run the application. Launch two emulators, two devices, or one emulator and one device, then open the application on both.

Enter a user name and log in. The screenshot above uses Esther and Brooklyn.
The first time you run the application it may need to create user and channel metadata. If the first view does not show the expected public channels or direct chat options, log out and log back in on each device.
You should see two public channels and the option to launch a direct chat with the other device.

Try the following:
- Open a public channel on both devices and send a message. The avatar for each user is randomly selected when the user is first created.
- React to a message by selecting one of the two emoji. The reaction update is sent to all active recipients.
- Back out of the public channel and reopen it. Message history and reactions are retained.
- Open a direct chat between the two participants.
- Send a direct message from one user to the other. The sample shows typing status and a read receipt after the message has been read.
How the Sample App Works
The rest of this tutorial reviews the main features of the sample application and explains the Kotlin Chat SDK APIs used by the app. The sample uses Jetpack Compose, but the PubNub Kotlin Chat SDK itself is UI-agnostic.
Initializing the Chat SDK
The first step is to create a Chat object and initialize it.
fun createChat(chatConfiguration: ChatConfiguration, userId: String): PNFuture<Chat> {
return Chat.init(
chatConfiguration,
PNConfiguration.builder(
UserId(userId),
PUBNUB_SUBSCRIBE_KEY
) {
publishKey = PUBNUB_PUBLISH_KEY
}.build()
)
}The publish and subscribe keys identify your PubNub keyset. The userId uniquely identifies the human user to PubNub. In production, this value usually comes from your authentication or identity system.
The sample app converts the name typed on the login screen into a simple PubNub user ID by replacing unsupported characters:
private fun convertToId(name: String): String {
val pattern = "[^a-zA-Z0-9]".toRegex()
return name.replace(pattern, "-").lowercase()
}The home screen initializes chat with a five second typing timeout:
chat = createChat(
ChatConfiguration(LogLevel.VERBOSE, typingTimeout = 5.seconds),
userId
).await()After the Chat SDK is initialized for a user ID, the app updates the current user's profile metadata if needed:
if (chat?.currentUser?.profileUrl == null || chat?.currentUser?.profileUrl == "") {
chat?.currentUser?.update(
name = name,
profileUrl = randomProfileUrl
)?.await()
}Creating Public Channels
Conversations using the Chat SDK happen over a Channel. The sample uses two channel types:
- Public: a channel anybody can participate in. Public chats do not show read receipts or typing indicators in this sample.
- Direct: a private conversation between two participants.
When the sample app is first initialized, it checks whether the public channels already exist:
publicChannels =
chat?.getChannels(filter = "id LIKE \"public*\"")?.await()?.channels?.toList()If the channels do not exist, the app creates them with chat.createPublicConversation():
val customObjectGeneral = JSONObject()
customObjectGeneral.put(
"profileUrl",
"$avatarBaseUrl/group/globe1.png"
)
chat?.createPublicConversation(
channelId = "public-general",
channelName = "General Chat",
channelDescription = "Public group for general conversation",
channelCustom = customObjectGeneral
)?.await()In production, public channels are usually created on the server or by an administrator. The tutorial keeps channel creation in the client so the sample can run without extra infrastructure.
If you have access to BizOps Workspace, you can see the created public channels in Channel Management.

Displaying and Creating 1:1 Conversations
During initialization, the sample queries the ten most recently updated users and shows them in the direct messages list:
allUsers = chat?.getUsers(
sort = listOf(PNSortKey.desc(PNKey.UPDATED)),
limit = 10
)?.await()?.users?.toList()This keeps the sample simple. A production app would usually provide search, invitations, contact lists, or some other controlled way to choose conversation participants.
When a user is selected, the app creates or retrieves a direct conversation with chat.createDirectConversation():
val result = chat.createDirectConversation(selectedUser).await()
activeChannel = result.channelThe returned result contains the direct channel. If the conversation already exists, the SDK returns the existing channel.
You can see the created direct channel in BizOps Workspace if BizOps is enabled on your account.

Sending and Receiving Messages
Sending messages is handled at the channel level with channel.sendText():
activeChannel?.sendText(messageText)?.await()The app joins the selected channel so the current user has a membership for unread counts and read receipts:
val myChannelMembership: Membership? =
channelMemberships?.firstOrNull() ?: activeChannel?.join()?.await()Message receipt is handled by channel.onMessageReceived():
channelMessageSubscription = activeChannel?.onMessageReceived {
messages = messages + it
scope.launch {
myChannelMembership?.setLastReadMessage(it)?.await()
}
restartMessageUpdates()
}The returned AutoCloseable is closed when the active channel changes so the app stops receiving messages for the previous channel.
Message History
To retrieve messages sent before the user opened the channel, enable Message Persistence on your PubNub keyset and call channel.getHistory():
activeChannel?.getHistory(count = 20)?.await()?.let {
messages = it.messages
messages.lastOrNull()?.let { lastMessage ->
myChannelMembership?.setLastReadMessageTimetoken(lastMessage.timetoken)?.await()
}
restartMessageUpdates()
}The sample loads the most recent 20 messages. The API can return up to 25 messages in a single call. To retrieve more history, call getHistory() multiple times and update startTimetoken and endTimetoken.
Message Reactions
The Chat SDK lets users react to messages and receive updates when reactions change.

To apply or remove an emoji reaction, call message.toggleReaction():
message?.toggleReaction(emoji)?.await()There is one toggle API rather than separate add and remove APIs. Emojis are represented as Unicode strings, so the API can work with new emoji introduced in the future.
The sample uses two hardcoded emoji that are shown on every message. A production app would usually let users choose from a reaction picker or a long-press menu.
To receive updates when a reaction changes, use Message.streamUpdatesOn():
fun restartMessageUpdates() {
messageActionsStream?.close()
messageActionsStream = if (messages.isNotEmpty()) {
Message.streamUpdatesOn(messages = messages) { updatedMessages ->
messages = updatedMessages.toList()
}
} else {
null
}
}Whenever a message changes, the callback receives the updated message collection. The sample replaces the message list to keep the state management simple.
Displaying message reactions
Reactions applied to a message are available through message.reactions:
message?.reactionsEach reaction includes the reaction value, whether it belongs to the current user, the user IDs that added it, and the total count:
MessageReaction(
value = "😀",
isMine = true,
userIds = listOf("esther", "brooklyn")
)The sample determines the visible count for each displayed emoji like this:
val count = message?.reactions?.firstOrNull { it.value == emoji }?.count ?: 0The platform is responsible for displaying emoji. Only exchange emoji that can be displayed on the recipient platform, or use an emoji compatibility library.
You can also check whether the current user has reacted with a specific emoji by calling message.hasUserReaction(emoji).
Read Receipts
Read receipts are available for group and direct channels. They are not shown for public channels in this sample.

At a high level, read receipts work like this:
- A client receives a message.
- The receiving client marks that message as read.
- Other clients in the chat are notified that the user's last-read timetoken changed.
Deciding when a message is read is an application-level choice. Many chat apps only mark a message as read after it appears in the visible scroll area. This sample keeps the logic simple and marks messages as read when they are received on the active channel.
The Chat SDK stores read state on the Membership, because read state belongs to the relationship between a user and a channel:
myChannelMembership?.setLastReadMessage(it)?.await()When history is loaded, the app marks the newest historical message as read:
messages.lastOrNull()?.let { lastMessage ->
myChannelMembership?.setLastReadMessageTimetoken(lastMessage.timetoken)?.await()
}Fetching and receiving read receipts
The app fetches the current read receipt state when a direct channel opens, then subscribes to new read receipt events:
if (activeChannel?.type != ChannelType.PUBLIC) {
readReceipts = activeChannel?.fetchReadReceipts()?.await()?.receipts ?: emptyList()
readReceiptStream = activeChannel?.onReadReceiptReceived { receipt ->
updateReadReceipt(receipt)
}
}Each receipt contains a userId and the user's latest lastReadTimetoken:
fun updateReadReceipt(receipt: ReadReceipt) {
readReceipts = readReceipts.filterNot { it.userId == receipt.userId } + receipt
}The sample uses the following basic logic to decide whether to show a read checkmark for a sent message:
val isRead = activeChannel?.type != ChannelType.PUBLIC &&
message != null &&
readReceipts.any {
it.userId != chat?.currentUser?.id && it.lastReadTimetoken >= message.timetoken
}This binary display is enough for a direct chat. In group conversations, you would usually show a more detailed state such as "Read by Esther and Brooklyn" by resolving receipt user IDs with chat.getUser(userId).
Typing Indicator
Typing indicators are available for direct and group chats.

The SDK handles the timeout logic usually associated with implementing typing indicators. Your app tells the SDK when somebody is typing, and the SDK emits updates when the active typing user list changes.
In the message input change handler, the sample calls channel.startTyping():
if (channel.type != ChannelType.PUBLIC) {
channel.startTyping().await()
}The Chat SDK treats repeated calls as "the user is still typing" and uses typingTimeout to decide when the user stopped typing. This sample configures typingTimeout = 5.seconds during chat initialization.
You can also manually call channel.stopTyping(), but most apps can rely on the timeout mechanism.
The sample uses a separate TypingIndicator component and subscribes to typing changes with channel.onTypingChanged():
if (channel.type != ChannelType.PUBLIC) {
val typingIndicators: Collection<String> by produceState<Collection<String>>(listOf()) {
val sub = channel.onTypingChanged {
value = it
}
awaitDispose {
sub.close()
}
}
if (typingIndicators.isNotEmpty()) {
// Update display of active typers.
}
}The API returns user IDs. A production app will often convert those IDs to names and avatars using chat.getUser(userId).
You have now finished the tutorial. Click "Next" below for the next steps.
Congratulations - what's next?
Chat SDK Documentation
- Jump straight into the PubNub Kotlin Chat SDK to understand the capabilities of the API
- Learn more about why we built the Chat SDK, the concepts, and terminology.
Understand the Source Code
This tutorial has only scratched the surface of the Kotlin Chat SDK. Check out the SDK source code, available at https://github.com/pubnub/kmp-chat
The source code for the sample app discussed in this tutorial is available from https://github.com/PubNubDevelopers/Chat-SDK-Tutorial-Kotlin
Chat SDK Demos in other Languages
Experience our fully-featured group chat app for the web, written in TypeScript and Next.js:

Ready to get beyond Proof of Concept?
Schedule a 15 minute chat with one of our experts to get your project moving
Got a question for our developer relations team? Email devrel@pubnub.com
Take it to production on your own keys
Create a free PubNub account to get permanent keys for this app.