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.
Kotlin
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.
Kotlin
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:
Kotlin
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:
Kotlin
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:
Kotlin
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:
Kotlin
publicChannels =
chat?.getChannels(filter = "id LIKE \"public*\"")?.await()?.channels?.toList()
If the channels do not exist, the app creates them with chat.createPublicConversation():
Kotlin
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:
Kotlin
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():
Kotlin
val result = chat.createDirectConversation(selectedUser).await()
activeChannel = result.channel
The 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():
Kotlin
activeChannel?.sendText(messageText)?.await()
The app joins the selected channel so the current user has a membership for unread counts and read receipts:
Kotlin
val myChannelMembership: Membership? =
channelMemberships?.firstOrNull() ?: activeChannel?.join()?.await()
Message receipt is handled by channel.onMessageReceived():
Kotlin
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():
Kotlin
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():
Kotlin
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():
Kotlin
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:
Each reaction includes the reaction value, whether it belongs to the current user, the user IDs that added it, and the total count:
Kotlin
MessageReaction(
value = "😀",
isMine = true,
userIds = listOf("esther", "brooklyn")
)
The sample determines the visible count for each displayed emoji like this:
Kotlin
val count = message?.reactions?.firstOrNull { it.value == emoji }?.count ?: 0
The 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:
Kotlin
myChannelMembership?.setLastReadMessage(it)?.await()
When history is loaded, the app marks the newest historical message as read:
Kotlin
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:
Kotlin
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:
Kotlin
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:
Kotlin
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():
Kotlin
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():
Kotlin
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.