Building a Real-Time Chat App
Introduction
Real-time chat features have become essential for modern apps in our hyper-connected world. Whether it’s customer support, social networking, or team collaboration, users expect instant messaging that just works seamlessly. But let’s face it—getting these features to play nice across multiple platforms can be a bit tricky.
That’s where Stream's Chat SDK comes into play. It’s a powerful tool that simplifies adding real-time chat functionality to your app. Pair it up with Kotlin Multiplatform (KMP), and you can write shared code once and deploy it across various platforms, saving you time and effort.
In a previous blog post, Stream showcased how to build your first simple app using KMP, if you want a step-by-step for your first app, give it a read!
In this blog post, we’ll dive into building a real-time chat application using Kotlin Multiplatform and Stream Chat SDK. We’ll walk you through the integration process, share some handy best practices, and discuss any limitations you should keep in mind.
Why Kotlin Multiplatform?
Kotlin Multiplatform is an innovative technology that allows developers to share code across multiple platforms, including Android, iOS, desktop, and web. By leveraging KMP, you can:
Increase Productivity: Write your business logic once and share it across platforms, reducing code duplication.
Ensure Consistency: Maintain a single codebase for core functionalities, ensuring consistent behavior across platforms.
Simplify Maintenance: Update shared code in one place, simplifying bug fixes and feature enhancements.
Enhance Flexibility: Opt for platform-specific implementations when necessary without affecting the shared codebase.
KMP strikes a balance between code sharing and platform-specific optimization, making it an excellent choice for cross-platform app development.
Overview of Stream's SDK
Stream provides scalable and feature-rich APIs for building real-time chat and messaging applications. The Stream Chat SDK offers:
Real-Time Messaging: Supports threads, reactions, typing indicators, and read receipts.
Customizable UI Components: Pre-built components that can be tailored to match your app's design.
Offline Support: Enables users to continue interacting with the app even without an internet connection.
High Performance: Designed to handle large volumes of data with low latency.
Security: Offers end-to-end encryption and compliance with data protection regulations.
By integrating Stream's SDK, we developers can focus on what matters, like creating engaging user experiences without worrying about the complexities of building a chat infrastructure from scratch.
Today we will explore and showcase many of these functionalities provided by the SDK.
Integrating Stream Chat with KMP
Let's dive into integrating Stream Chat SDK into a Kotlin Multiplatform project. We'll cover setting up the project, adding dependencies, and implementing chat features using shared code in commonMain
(the part that would be shared across platforms).
Also, on our way to building the app, we will cover many interesting topics like how to handle secrets in a KMP project and how to handle application resources across platforms in KMP.
Project Setup
We begin by creating a new Kotlin Multiplatform project using the Kotlin Multiplatform Wizard. The wizard generates a basic project structure with shared code in the commonMain
source set.
I called the project Chatto, I selected the option to share the UI because even tho the Stream SDK still does not support fully KMP, we can build our app and use it in the shared logic part to demonstrate the usage.
Adding Dependencies
In the gradle/libs.versions.toml
we will need some dependencies to get our project up and running:
[versions]
agp = "8.2.2"
android-compileSdk = "34"
android-minSdk = "24"
android-targetSdk = "34"
androidx-activityCompose = "1.9.1"
androidx-lifecycle = "2.8.0"
compose-plugin = "1.6.11"
kotlin = "2.0.20"
stream-chat = "6.5.0"
build-konfig = "0.15.2"
uiAndroid = "1.6.7"
androidx-navigation = "2.7.0-alpha07"
[libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
stream-chat-compose = { group = "io.getstream", name = "stream-chat-android-compose", version.ref = "stream-chat" }
stream-chat-offline = { group = "io.getstream", name = "stream-chat-android-offline", version.ref = "stream-chat" }
androidx-ui-android = { group = "androidx.compose.ui", name = "ui-android", version.ref = "uiAndroid" }
androidx-navigation = { group = "org.jetbrains.androidx.navigation", name = "navigation-compose", version.ref = "androidx-navigation" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
buildKonfig = { id = "com.codingfeline.buildkonfig", version.ref = "build-konfig" }
We use some libraries and plugins to run the project, for instance:
buildKonfig
is to manage the credentials that you do not want in your source code, you can think about this one as a very similar equivalent to Android’sBuildConfig
, but compatible with KMPnavigation
is to manage the navigation between the screens in the chat app with code that is totally platform-independent
In your build.gradle.kts
file for the composeApp
module, include the necessary dependencies:
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsCompose)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.buildKonfig)
}
kotlin {
androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation)
implementation(libs.stream.chat.compose)
implementation(libs.stream.chat.offline)
}
androidMain.dependencies {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
}
}
}
Managing Secrets with BuildKonfig
To securely handle your Stream API key and client token, use the BuildKonfig plugin:
buildkonfig {
packageName = "dev.zaitech.chatto"
objectName = "Secrets"
val props = Properties()
try {
props.load(file(rootProject.file("local.properties").absolutePath).inputStream())
} catch (e: Exception) {
// Keys are private and not committed to git
}
defaultConfigs {
buildConfigField(
Type.STRING,
"STREAM_API_KEY",
props["stream_api_key"].toString()
)
buildConfigField(
Type.STRING,
"STREAM_CLIENT_TOKEN",
props["stream_client_token"].toString()
)
}
}
Ensure your local.properties
file (excluded from version control) contains:
stream_api_key=__your_stream_api_key__
stream_client_token=__your_stream_client_token__
For test purposes, I used the client token in the Android sample of Stream, which can be found here.
The reason I used it is because it makes it easy to demo the application since these users have channels and messages already, feel free to use it or generate your own.
You can do this by creating an account and starting to code for free, visit Stream try-for-free page.
For more info on how to generate these tokens please check Stream Tokens & Authentication.
Implementing the Chat Client in commonMain
We have configured the ChatClient
in a StreamChat.kt
file located in the commonMain
directory.
By keeping all the code in commonMain
, we ensure it is shared across all platforms, maintaining a clean and consistent codebase.
Here's how we initialize and set up the ChatClient
:
@Composable
fun rememberClientInitializationState(client: ChatClient): State<InitializationState> {
return client.clientState.initializationState.collectAsState()
}
@Composable
fun rememberChatClient(): ChatClient {
val context = LocalContext.current
// Initialize plugins for offline support and state management
val offlinePluginFactory = remember {
StreamOfflinePluginFactory(appContext = context)
}
val statePluginFactory = remember {
StreamStatePluginFactory(StatePluginConfig(), appContext = context)
}
// Build the ChatClient with the plugins
val client = remember {
ChatClient.Builder(Secrets.STREAM_API_KEY, context)
.withPlugins(offlinePluginFactory, statePluginFactory)
.logLevel(ChatLogLevel.ALL) // Switch to ChatLogLevel.NOTHING in production
.build()
}
// Connect the user asynchronously
LaunchedEffect(client) {
val user = User(
id = "leandro",
name = "Leandro Borges Ferreira",
image = "https://example.com/user-image.png",
)
client.connectUser(
user = user,
token = Secrets.STREAM_CLIENT_TOKEN,
).enqueue { result ->
if (result.isSuccess) {
Log.d("ChatClient", "User connected successfully")
} else {
Log.e("ChatClient", "Error connecting user: $result")
}
}
}
return client
}
In this code, we define two composable functions: rememberClientInitializationState
and rememberChatClient
. The rememberClientInitializationState
function collects the initialization state of the ChatClient
, allowing the UI to react to any changes in the client's state.
The rememberChatClient
function initializes ChatClient
with plugins for offline support and state management. By using remember
, we ensure that the client is only initialized once during the composition lifecycle (and across recompositions).
Understanding LocalContext
It is a property provided by Compose Multiplatform that gives us access to the current context in which the composable is running. In a multiplatform setup, this context abstracts away platform-specific details, allowing us to write code that is shared across different platforms.
Some examples of what this context can contain are:
• System Services: Ability to interact with system-level services like connectivity managers, which can be useful for checking network status or handling notifications.
• Theme and Styling Information: Information about the current theme or styling, enabling you to style components consistently across platforms.
• Platform-Specific Configurations: Settings or configurations that are specific to the platform but are exposed in a way that can be used in shared code.
By using LocalContext.current, we’re able to pass the necessary context to the Stream SDK’s factories and builders. This ensures that the SDK has all the information it needs to function correctly, such as accessing resources or system services, without us having to write platform-specific code.
Then, we set up the offline and state plugins using StreamOfflinePluginFactory
and StreamStatePluginFactory
.
These plugins add offline capabilities and efficient state management to the ChatClient
, which are essential features for a reliable chat application.
User connection is handled asynchronously within a LaunchedEffect
, ensuring it doesn't block the UI thread. We create a User
object with the necessary details and call client.connectUser
, passing in the user and the authentication token.
This approach efficiently manages the authentication process without interrupting the UI flow.
By structuring our code this way, we maintain a shared, multiplatform setup that is both clean and easy to maintain. Using LocalContext
and avoiding platform-specific code ensures that our application remains scalable and adaptable to different environments.
Building the UI with Compose Multiplatform
We utilize Jetpack Compose Multiplatform to create the UI in commonMain
:
@Composable
fun App() {
val chatClient = rememberChatClient()
val clientInitializationState by rememberClientInitializationState(chatClient)
val navController = rememberNavController()
ChatTheme {
NavHost(navController = navController, startDestination = "channels") {
composable("channels") {
when (clientInitializationState) {
InitializationState.COMPLETE -> {
ChannelsScreen(
title = stringResource(Res.string.app_name),
isShowingHeader = true,
searchMode = SearchMode.Channels,
onChannelClick = { channel ->
navController.navigate("messages/${channel.type}:${channel.id}")
},
onBackPressed = {
navController.popBackStack()
},
)
}
InitializationState.INITIALIZING -> {
Text(text = "Initializing...")
}
InitializationState.NOT_INITIALIZED -> {
Text(text = "Not initialized...")
}
}
}
composable(
"messages/{channelId}",
arguments = listOf(navArgument("channelId") { type = NavType.StringType })
) { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId")
if (channelId != null) {
MessagesScreen(
viewModelFactory = MessagesViewModelFactory(
context = LocalContext.current,
channelId = channelId
),
onBackPressed = {
navController.popBackStack()
}
)
}
}
}
}
}
We use NavController
from Compose Multiplatform to handle in-app navigation between screens, this is still in Alpha and is a very powerful tool to handle navigations without having to write platform-specific code.
The ChannelsScreen
and MessagesScreen
are provided by Stream's Compose UI components.
We handle the loading state and display different UI elements based on the clientInitializationState
.
Also, to handle resources in a cross-platform way, using Compose Resources made it a piece of cake, a function like stringResource
getting the title of the app Chatto from the resources and using it in the UI. This resource and its retrieval are independent, more info on how to handle resources other than strings is here.
As can be noticed, all UI components are defined and implemented in commonMain
, emphasizing the cross-platform capability.
Android Entry Point
The Android-specific code is minimal, with MainActivity
simply rendering the App
composable without any platform-specific implementation:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
App()
}
}
}
@Preview
@Composable
fun AppAndroidPreview() {
App()
}
The fact that MainActivity
only sets the content to App()
demonstrates the power of KMP and Compose Multiplatform. It allows us to write almost all our code—including UI and business logic—in commonMain
, with minimal platform-specific code.
Chatto
Now the exciting part, let’s see the results:
This is the rendered channels (chats) screen, with the app title being retrieved from compose-multiplatform
The search functionality works out of the box, as configured in the code it searches over the channel names
Here we can open a conversation, type and send messages
Here, we can send the message and vote on an existing poll
We can also create a poll with options
The poll created in the chat and voted on
We can also do operations on messages, like editing, deleting, replying, reacting, pinning etc.
Here we can see the reactions added to the message and poll that we sent and created:
All the powerful features the Stream SDK offers work seamlessly within our KMP setup.
Best Practices for KMP and Stream SDK Integration
When integrating Stream Chat SDK with Kotlin Multiplatform, consider the following best practices:
Optimize Performance
Using remember
and LaunchedEffect
: Cache objects and perform side effects efficiently to prevent unnecessary recompositions.
Use Compose's state management to handle UI updates smoothly.
Code Organization
Maximize Shared Code: Keep as much code as possible in commonMain
to benefit from code sharing.
Platform-Specific Code: Isolate any required platform-specific implementations, but the goal is always to minimize them.
Testing and Maintenance
Write Shared Tests: Place your tests in commonTest
to verify shared code across platforms. Not included in this blog because it is mostly about integrating the items without a lot of business logic involved yet, can be added later as the business logic grows.
Continuous Integration: Set up CI pipelines to build and test your code for all target platforms, you can also configure the tests to run for all platforms inside the CI, for this you might need to specify a MacOS machine for the pipeline, to cover the majority of the different platforms.
⚠︎Be Aware of Current Platform Limitations⚠︎
Currently, the Stream Chat SDK client only supports Android. That’s why although we implemented everything in KMP and shared logic, the application would not run on iOS and other native applications.
Eventually, when support is added, the shared logic can be reused if no breaking changes are introduced..
Conclusion
By combining Kotlin Multiplatform with Stream's Chat SDK, we can build powerful, real-time chat applications with a shared codebase. This approach not only accelerates development but also ensures consistency and maintainability across platforms.
While there are some limitations—such as the current lack of iOS support for the Stream Chat SDK in KMP—the benefits are significant. As the ecosystem evolves, we can expect even broader platform support, making KMP an increasingly valuable tool for cross-platform development.
Stream's offerings and Kotlin Multiplatform might be a great fit for your next project. Embrace the future of app development by writing less code and delivering more value to your users.
You can find the source code for the full project on GitHub.
Happy coding!
Subscribe to my newsletter
Read articles from Abdullah Zaiter directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Abdullah Zaiter
Abdullah Zaiter
In my current role as the lead engineer, I design, develop, and scale sophisticated e-commerce platforms. My role with a leading Dutch retailer has been particularly impactful. I've leveraged my expertise to scale the platform to handle hundreds of thousands of orders and significantly boost its revenue by hundreds of millions of euros. A notable accomplishment was expanding the platform's operations from the Netherlands to Belgium, transforming it into a versatile, multi-lingual, and multi-country system. Additionally, I am passionate about mentorship and education, dedicating time as a Software Engineering Mentor at adplist.org, where I guide and help the next generation of Software Engineers.