DEV Community

Cover image for Building Real-Time Chat with Kotlin Multiplatform and Stream Chat SDK
Zaiter
Zaiter

Posted on

Building Real-Time Chat with Kotlin Multiplatform and Stream Chat SDK

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).

Why Kotlin Multiplatform?
Kotlin Multiplatform is an innovative technology that allows developers to share code across multiple platforms, including Android, iOS, desktop, and web. KMP strikes a balance between code sharing and platform-specific optimization, making it a great choice for cross-platform app development.
In a previous blog post, Stream showcased how to build your first app using KMP, if you want to learn more about the benefits of KMP and a step-by-step for your first app, give it a read!

In this blog post, we will leverage KMP to build a real-time chat application using 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.

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.

Kotlin Multiplatform Wizard

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" }
Enter fullscreen mode Exit fullscreen mode

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’s BuildConfig, but compatible with KMP
  • navigation 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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Ensure your local.properties file (excluded from version control) contains:

stream_api_key=__your_stream_api_key__
stream_client_token=__your_stream_client_token__
Enter fullscreen mode Exit fullscreen mode

For test purposes, I used the client token in the Android sample of Stream, which 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
}
Enter fullscreen mode Exit fullscreen mode

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()
                        }
                    )
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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

Chats Screen

The search functionality works out of the box, as configured in the code it searches over the channel names

Channels Search

Here we can open a conversation, type and send messages

Conversation Screen

Here, we can send the message and vote on an existing poll

Send message & vote

We can also create a poll with options

Create a poll

The poll created in the chat and voted on

Poll in the chat

We can also do operations on messages, like editing, deleting, replying, reacting, pinning etc.
Message operations

Here we can see the reactions added to the message and poll that we sent and created:
Reaction shown on message and poll

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!

Top comments (0)