This tutorial teaches you how to build in-app chat or messaging with Jetpack Compose & Stream's edge network.
On the right, you see a GIF of how the end result will look. A sample app is also available on our repo.
This chat tutorial uses Stream's edge network for optimal performance. For hobby projects and small companies, we provide a free maker plan.
In case you can't use Compose yet in your app, an XML Android chat tutorial is also available. Let's start with the tutorial and see how quickly you can build chat for your app.
Main Features
These are the main features that our chat app will have:
- Channel List: Browse channels and perform actions such as search and swipe-to-delete.
- Message Composer: Customizable and expandable with bespoke implementation.
- Message Reactions: Ready-made and easily configurable.
- Offline Support: Browse channels and send messages while offline.
- Customizable Components: Build quickly with customizable and swappable building blocks.
Installation
Create a New Android Studio Project
To get started with the Jetpack Compose version of the Android Chat SDK, open Android Studio (Giraffe or newer) and create a new project.
- Select the
Empty Activity
template. - Name the project
ChatTutorial
. - Set the package name to
com.example.chattutorial
.
Once you create and load the project, you must add appropriate dependencies for Jetpack Compose. Our SDKs are available from MavenCentral.
Let's add the Stream Chat Compose SDK to the project's dependencies.
For the tutorial we will add offline support by adding the stream-chat-android-offline
plugin dependency.
You'll also add a dependency on material-icons-extended
, as you'll use an icon provided there for customization in later steps.
Open up the app module's build.gradle.kts
(or build.gradle
if you are using older style Groovy DSL) file and add the following three dependencies:
12345678dependencies { implementation(libs.stream.chat.android.compose) implementation(libs.stream.chat.android.offline) implementation(libs.androidx.material.icons.extended) ... }
12345678dependencies { implementation 'libs.stream.chat.android.compose' implementation 'libs.stream.chat.android.offline' implementation 'libs.androidx.material.icons.extended' ... }
The Compose Chat SDK requires compileSdk
version to be set to 34 or higher. Android Studio version Hedgehog or newer will set 34+ automatically for new projects. You can verify the compileSdk
version in build.graddle
in your project folder (usually named app
):
123456... android { namespace = "com.example.chattutorial" compileSdk = 34 ... }
To simplify the process of trying out the sample code you can also copy the following imports into your MainActivity.kt
:
12345678910111213141516import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.material.Text import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.res.stringResource import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.logger.ChatLogLevel import io.getstream.chat.android.compose.ui.channels.ChannelsScreen import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.User import io.getstream.chat.android.offline.plugin.factory.StreamOfflinePluginFactory import io.getstream.chat.android.state.plugin.config.StatePluginConfig import io.getstream.chat.android.state.plugin.factory.StreamStatePluginFactory
You now have an empty project for your chat app with the Stream Jetpack Compose Chat SDK as a dependency. Let's start by creating the chat client.
Client Setup
Step 1: Setup the ChatClient
First, we need to setup the ChatClient
. For this, go to the created MainActivity.kt
file and add the following code inside the onCreate()
function:
123456789101112131415161718class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 1 - Set up the OfflinePlugin for offline storage val offlinePluginFactory = StreamOfflinePluginFactory(appContext = applicationContext) val statePluginFactory = StreamStatePluginFactory(config = StatePluginConfig(), appContext = this) // 2 - Set up the client for API calls and with the plugin for offline storage val client = ChatClient.Builder("uun7ywwamhs9", applicationContext) .withPlugins(offlinePluginFactory, statePluginFactory) .logLevel(ChatLogLevel.ALL) // Set to NOTHING in prod .build() ... } }
- You create a
StreamOfflinePluginFactory
to provide offline support. The OfflinePlugin class employs a new caching mechanism powered by side-effects we applied to ChatClient functions. - You create a connection to Stream by initializing the
ChatClient
using an API key. This key points to a tutorial environment, but you can sign up for a free Chat trial to get your own later. - Next, we add the
offlinePluginFactory
to theChatClient
withwithPlugin
method for providing offline storage capabilities. For a production app, we recommend initializing thisChatClient
in yourApplication
class.
Step 2: Connect the User
As a next step, we'll connect a user to the chat. The following example will demonstrate how a user can be authenticated using a JWT token. It's also possible to have anonymous or guest users. For a comprehensive understanding of connecting and authenticating users, refer to the auth & connect docs. In this instance, a hardcoded JWT token is used. In a production application, the JWT token is generally supplied as part of your backend's login and registration endpoints.
Here's our connectUser
implementation, which authenticates the user. You should call this method right after setting up the client
instance.
12345678910111213141516171819class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ... // 3 - Authenticate and connect the user val user = User( id = "tutorial-droid", name = "Tutorial Droid", image = "https://bit.ly/2TIt8NR" ) client.connectUser( user = user, token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidHV0b3JpYWwtZHJvaWQifQ.WwfBzU1GZr0brt_fXnqKdKhz3oj0rbDUm2DqJO_SS5U" ).enqueue() } }
- You create a
User
instance and pass it to theChatClient
'sconnectUser
method and a pre-generated user token to authenticate the user. In a real-world application, your authentication backend would generate a token at login/signup and hand it over to the mobile app. For more information, see the Tokens & Authentication page. - The
connectUser.enqueue()
is an asynchronous function with a result callback; in Kotlin, you can use the suspending function by usingawait()
instead. Executing the function synchronously withexecute()
is also possible. Our Calls section explains this in more detail.
Presenting a Channel List
There are two ways you can build the UI for the channel list.
- Stream's low-level API. You can build a custom UI on top of this state layer.
- Or you can use some of our pre-made UI components. Most customers mix and match these two approaches to meet their design requirements.
Let's try how the ChannelsScreen
can be easily implemented by adding the following code to the MainActivity.kt
file:
12345678910111213141516171819202122232425262728293031323334class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ... setContent { // Observe the client connection state val clientInitialisationState by client.clientState.initializationState.collectAsState() ChatTheme { when (clientInitialisationState) { InitializationState.COMPLETE -> { ChannelsScreen( title = stringResource(id = R.string.app_name), isShowingHeader = true, onChannelClick = { channel -> TODO() }, onBackPressed = { finish() } ) } InitializationState.INITIALIZING -> { Text(text = "Initialising...") } InitializationState.NOT_INITIALIZED -> { Text(text = "Not initialized...") } } } } } }
- Observe the Chat SDK initialization state (
client. client state.initializationState
StateFlow) to ensure that the SDK is correctly initialized and the user has been set. Note that this doesn't mean that the SDK is online and connected—for that, you can observe theclient.clientState.connectionState
. - Call the
ChannelsScreen
composable function to render the entireChannelsScreen
and wrap it in our theme, which provides styling options. You also pass in the app name as the screen's title and an onBackPressed listener.
Here is the full code so far:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273package com.example.chattutorial import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.material.Text import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.res.stringResource import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.logger.ChatLogLevel import io.getstream.chat.android.compose.ui.channels.ChannelsScreen import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.User import io.getstream.chat.android.offline.plugin.factory.StreamOfflinePluginFactory import io.getstream.chat.android.state.plugin.config.StatePluginConfig import io.getstream.chat.android.state.plugin.factory.StreamStatePluginFactory class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 1 - Set up the OfflinePlugin for offline storage val offlinePluginFactory = StreamOfflinePluginFactory(appContext = applicationContext,) val statePluginFactory = StreamStatePluginFactory(config = StatePluginConfig(), appContext = this) // 2 - Set up the client for API calls and with the plugin for offline storage val client = ChatClient.Builder("uun7ywwamhs9", applicationContext) .withPlugins(offlinePluginFactory, statePluginFactory) .logLevel(ChatLogLevel.ALL) // Set to NOTHING in prod .build() // 3 - Authenticate and connect the user val user = User( id = "tutorial-droid", name = "Tutorial Droid", image = "https://bit.ly/2TIt8NR" ) client.connectUser( user = user, token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidHV0b3JpYWwtZHJvaWQifQ.WwfBzU1GZr0brt_fXnqKdKhz3oj0rbDUm2DqJO_SS5U" ).enqueue() setContent { // Observe the client connection state val clientInitialisationState by client.clientState.initializationState.collectAsState() ChatTheme { when (clientInitialisationState) { InitializationState.COMPLETE -> { ChannelsScreen( title = stringResource(id = R.string.app_name), isShowingHeader = true, onChannelClick = { channel -> TODO() }, onBackPressed = { finish() } ) } InitializationState.INITIALIZING -> { Text(text = "Initializing...") } InitializationState.NOT_INITIALIZED -> { Text(text = "Not initialized...") } } } } } }
Composable UI Components rely on a
ChatTheme
being present somewhere above them in the UI hierarchy. Make sure you add this wrapper whenever you're using the components of the Chat SDK. Learn more in the ChatTheme documentation.
Build and run your application. You should see the channel screen interface shown on the right. Notice how easy it was to build a fully functional screen with Compose!
Internally, the ChannelsScreen
uses these smaller components:
- ChannelListHeader: Displays information about the user, app and exposes a header action.
- SearchInput: Displays an input field, to query items. Exposes value change handlers to query new items from the API.
- ChannelList: Displays a list of
Channel
items in a paginated list and exposes single and long tap actions on items.
If you want to customize this screen's look or behavior, you can build it from these individual components. For more details, see the Component Architecture page of the documentation.
Now that you can display channels, let's open up one of them and start chatting!
Presenting a Channel
To start chatting, you must build another screen - the Channel Screen.
Create a new Empty Activity (New -> Compose -> Empty Activity) and name it ChannelActivity
.
Make sure that
ChannelActivity
is added to your manifest. Android Studio does this automatically if you use the wizard to create the Activity, but you'll need to add it yourself if you manually create the Activity class.
After creating the Activity, add the following attribute to the ChannelActivity entry in your AndroidManifest.xml
:
1android:windowSoftInputMode="adjustResize"
The above will ensure the Activity adjusts properly when the input field is in focus.
Next, replace the code in ChannelActivity
with the following:
1234567891011121314151617181920212223242526272829303132333435363738394041424344package com.example.chattutorial import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import io.getstream.chat.android.compose.ui.messages.MessagesScreen import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.viewmodel.messages.MessagesViewModelFactory class ChannelActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 1 - Load the ID of the selected channel val channelId = intent.getStringExtra(KEY_CHANNEL_ID)!! // 2 - Add the MessagesScreen to your UI setContent { ChatTheme { MessagesScreen( viewModelFactory = MessagesViewModelFactory( context = this, channelId = channelId, messageLimit = 30 ), onBackPressed = { finish() } ) } } } // 3 - Create an intent to start this Activity, with a given channelId companion object { private const val KEY_CHANNEL_ID = "channelId" fun getIntent(context: Context, channelId: String): Intent { return Intent(context, ChannelActivity::class.java).apply { putExtra(KEY_CHANNEL_ID, channelId) } } } }
Let's review what's going on in this snippet:
- You load the
channelId
from the Intent extras. If there is no channel ID, you can't show messages, so you finish theActivity
and return. Otherwise, you can proceed to render the UI. - Similar to the ChannelsScreen, the
MessagesScreen
component sets up everything for you to show a list of messages and build a Chat experience. Note how this screen's composable should also wrapped inChatTheme
. TheMessagesScreens
requires aMessagesViewModelFactory
instance - you can learn more about it in our documentation. - Set up a helper function to build an
Intent
for thisActivity
, that populates the arguments with the channel ID.
Lastly, you want to launch ChannelActivity
when you tap a channel in the channel list. Open MainActivity
and replace the TODO()
within onChannelClick
with the following:
123onChannelClick = { channel -> startActivity(ChannelActivity.getIntent(this, channel.cid)) },
Run the application and tap on a channel. The chat interface will now appear on the right.
The MessagesScreen
component is the second screen component in the SDK. Out of the box, it provides you with the following features:
- Header: Displays a back button, the name of the channel or thread, and a channel avatar.
- Messages: Shows a paginated list of messages if the data is available; otherwise, it displays the correct empty or loading state. It sets up action handlers and displays a button for a quick scroll to the bottom action.
- Message composer: It handles message input, attachments, and actions like editing, replying, and more.
- Attachment picker: It allows users to select images, files, and media capture.
- Message options: Shown when the user selects the message by long tapping. Allows the user to react to messages and perform different actions such as deleting, editing, replying, starting a thread, and more.
- Reactions menu: This is shown when the user taps on a reaction to a message. It displays all of the reactions left on the message, along with the option to add or change yours.
You can explore all of these components individually, combine them to your requirements, and explore the Compose UI Components documentation to see how they behave and how you can customize them.
More complex code samples can also be found in our GitHub sample repository:
- MessagesActivity3 - uses bound and stateless components to build the chat screen, with further customization
- MessagesActivity4 - uses a custom message composer component for extended customization
With our Android Chat SDK, customization possibilities are limitless. You can effortlessly customize your user experience through theming, build unique components from scratch, or mix and match existing elements to suit your app's personality and functionality.
Theming
To change the theming of all the components wrapped by ChatTheme
, you just have to override its default parameters. Let's do that with the shapes
parameter. Change the setContent()
code in ChannelActivity.kt
to the following:
1234567891011121314151617181920setContent { ChatTheme( shapes = StreamShapes.defaultShapes().copy( avatar = RoundedCornerShape(8.dp), attachment = RoundedCornerShape(16.dp), myMessageBubble = RoundedCornerShape(16.dp), otherMessageBubble = RoundedCornerShape(16.dp), inputField = RectangleShape, ) ) { MessagesScreen( viewModelFactory = MessagesViewModelFactory( context = this, channelId = channelId, messageLimit = 30 ), onBackPressed = { finish() } ) } }
With this small change, you can override the default shapes used in our Compose UI Components.
You made the following changes:
- Instead of having rounded corners, you made the
inputField
rectangular. - The owned and other people's messages will be rounded on all corners, regardless of their position in a message group
- Avatars and attachments have rounded corners of
8dp
and16dp
, respectively.
Notice how you changed the theme shapes using copy()
. For ease of use, you can fetch the default theme values and use copy()
on the data class to change the properties you want to customize.
If you build the app now and open the messages screen, you'll see that the messages are all rounded, the input is rectangular, and the avatars are now squircles! That was easy!
Combining Components
The following customization step combines our bound and stateless components instead of using the screen components. This gives you control over which components you use and render and what behavior occurs when tapping items, selecting messages, and more.
You can inspect how the ChannelActivity
can be customized in our GitHub sample here.
In this example, we replace the high-level component MessagesScreen
with a custom UI built from our UI components.
Video / Audio Room Integration
For a complete social experience, Stream provides a Video & Audio calling Android SDK, that works seamlessly with our chat products. If you want to learn more about integrating video into your apps, please check our docs and our tutorials about video calling and livestreaming.
Additionally, we provide a guide on seamlessly integrating video with chat.
Final Thoughts
We've guided you through crafting a feature-rich, in-app chat experience with Android Jetpack Compose — with reactions, threads, typing indicators, offline storage, URL previews, user presence, and more. It's astonishing how APIs and Compose components empower you to bring a chat to life in hours. Beyond that, you've learned how effortlessly you can add your personal touch with custom themes and fully tailor key components.
Our chat app leverages Stream's edge network, ensuring optimal performance and scalability. It supports thousands of apps and over a billion end users. There's a free development plan, and for hobby projects and small apps, we offer a more extensive free maker plan. Check the available price tiers.
Both the Chat SDK for Compose and the API boast a plethora of additional features for advanced use cases, including push notifications, content moderation, rich messages, and more. Furthermore, we've demonstrated how to tap into our low-level state from the chat client for those inclined to craft their own custom messaging experiences.