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, there's also an XML Android chat tutorial available Let's get started 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 need to 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("io.getstream:stream-chat-android-compose:6.4.3") implementation("io.getstream:stream-chat-android-offline:6.4.3") implementation("androidx.compose.material:material-icons-extended:1.6.0-alpha08") ... }
12345678dependencies { implementation 'io.getstream:stream-chat-android-compose:6.4.3' implementation 'io.getstream:stream-chat-android-offline:6.4.3' implementation 'androidx.compose.material:material-icons-extended:1.6.0-alpha08' ... }
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
):
12345... 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 get started 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:
1234567891011121314151617class 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're going to connect a user to the chat. The following example will demonstrate the authentication of a user 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 harcoded JWT token is employed. 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 that authenticates the user. You should call this method right after the setup of the client
instance.
123456789101112131415161718class 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, along with a pre-generated user token, in order to authenticate the user. In a real-world application, your authentication backend would generate such 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 asychronous function with a result callback, in Kotlin you can also take advantage of suspending function by usingawait()
instead. It is also possible to execute the function synchronously withexecute()
. This is explained in more detail in our Calls section.
Presenting a channel list
There are 2 ways you can build the UI for the channel list
- Stream's low level API. You can build custom UI on top of this state layer
- Or you can use some of our pre-made UI components. Most customers end up mixing and matching between these 2 approaches to meet their design requirements
Let's try how the ChannelsScreen
can be easily implemented by adding the following
123456789101112131415161718192021222324252627282930313233class 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), isShowingSearch = true, onChannelClick = { channel -> TODO() }, onBackPressed = { finish() } ) } InitializationState.INITIALIZING -> { Text(text = "Initialising...") } InitializationState.NOT_INITIALIZED -> { Text(text = "Not initialized...") } } } } }
- You observe the Chat SDK initialisation state (
client.clientState.initializationState
StateFlow) to make sure that the SDK is correctly initialised 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
. - You 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), isShowingSearch = 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 what this screen looks like, or how it behaves, you can build it from these individual components. See the Component Architecture page of the documentation for more details.
Now that you can display channels, let's open up one of them and start chatting!
Presenting a channel
To start chatting, you need to 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 created the Activity class.
After creating the Activity, add the following attribute to the ChannelActivity entry in your AndroidManifest.xml
:
1android:windowSoftInputMode="adjustResize"
This will make sure the Activity adjusts properly when you focus the input field.
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:
123onItemClick = { channel -> startActivity(ChannelActivity.getIntent(this, channel.cid)) },
Run the application and tap on a channel: you'll now see the chat interface shown 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 displays the correct empty or loading state. Sets up action handlers and displays a button for quick scroll to bottom action.
- Message composer: Handles message input, attachments and message actions like editing, replying and more.
- Attachment picker: Allows the user 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: Shown when the user taps on a reaction to a message. 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 be also 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, the possibilities customisation possibilities are limitless. Customize your user experience effortlessly 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
, all you have to do is 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:
- You made the
inputField
rectangular, instead of having rounded corners. - 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 just the properties you want to customize.
If you build the app now and open the messages screen, you'll see how the messages are all rounded, the input is rectangular and the avatars are now a squircle! That was easy!
Combining components
The next step of customization is combining our bound and stateless components instead of using the screen components. This gives you more control over which components you use and render and what the behavior is when tapping on items, selecting messages and more.
You can inspect how the ChannelActivity
can be customised in our GitHub sample here.
In this example we replace the high-level component MessagesScreen
with a custom UI build from our UI components instead.
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 on how to integrate 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 — complete 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 just 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, supporting thousands of apps and over a billion end users. For development, there's a free 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.