Learn how to quickly integrate rich Generative AI experiences directly into Stream Chat. Learn More

Kotlin Chat Messaging Tutorial

How to build an Android chat app with Kotlin and XML views

This tutorial shows you how to get up and running with the Stream Chat SDK to add rich messaging features to your app using the XML UI components.

We'll start with a quick working integration, then look at theming and customization.

Prefer to skip the setup? Add the Stream skill and let your AI agent build your Android chat app.

This tutorial teaches you how to build a fully featured Android messaging app with Kotlin and the Stream Chat SDK XML UI components — channel list, message composer, reactions, threads, typing indicators, and offline support. You can use it as the foundation to build any type of in-app chat or messaging.

On the right is a preview of the finished app. A complete sample app is also available in our repo.

This page works for both humans and AI coding agents: every step is a file operation or shell command, every step has a verification checkpoint, and the few steps that need a human are explicitly marked.

Your app runs on Stream's edge network for optimal performance, across multiple pricing tiers including a free maker plan.

Channel list chat interface for Kotlin on Android

Using Jetpack Compose? Follow the Android Compose chat tutorial instead.

Choose your path

Install the Stream CLI once, then add the skills. This gives Claude Code, Cursor, or Codex the Android integration patterns and current SDK APIs, so it builds against real docs instead of stale training data.

bash
1
2
3
4
5
6
7
8
9
# Install the getstream CLI curl -fsSL https://getstream.io/cli.sh | bash # Install the skills (router, docs, and builder) for your agent. Pick the # target: --universal for Cursor, Codex, and other AGENTS-ecosystem tools, # or --claude for Claude Code. The Android pack installs on demand the first # time it's needed, or add it explicitly: getstream skills --universal getstream skills stream-android --universal

Then ask your agent:

plaintext
1
2
3
4
/stream-android Add Stream Chat to my Android app using the XML UI components, with a channel list and message screen. Provision credentials with the CLI (create/select my org and app, mint a token), or fall back to the tutorial demo credentials if I'm not logged in.

The agent pauses and hands back to you at three points: browser login during getstream init, creating the Android Studio project, and running the app.

Path B — Build it manually

Follow the steps below. Every code block is a complete file — no fragments to assemble.

For AI assistants reading this page: append .md to any Stream docs URL for a clean Markdown version. A condensed index for the Android SDK is at https://getstream.io/chat/docs/sdk/android/llms.txt. Prefer these over parsing HTML.

Important Building Blocks

Three pieces, one mental model:

  • ChatClient — Low-level API client. Handles auth, websockets, and offline storage. Built once, at app launch.
  • XML UI componentsChannelListView for the list; ChannelHeaderView, MessageListView, and MessageComposerView for a conversation. You place them in layouts like any other Android view.
  • ViewModels — each view has a matching ViewModel (ChannelListViewModel, MessageListViewModel, …) connected via a bindView call. Loose coupling makes each piece easy to customize.

You'll get a working app with the default UI first, then theme it, then customize a component. Deeper customizations are linked at the end.

Prerequisites

  • Android Studio (Giraffe or newer)
  • compileSdk 35 (or higher), minSdk 24, JVM target 11
  • Kotlin 2.2.0 (or higher) — the Chat SDK artifacts are compiled with Kotlin 2.2.0; an older Kotlin version (e.g. 2.0.x) fails at compile time with an opaque "Internal compiler error ... metadata 2.2.0, expected 2.0.0"
  • Stream Chat Android UI Components — you add this in Step 2; use the latest release

This tutorial is Kotlin-first. The SDK also supports Java — see the Java sample if you need it.

Step 0 — Provision credentials with the Stream CLI

You need an API key and a user token, both belonging to your own Stream app. The getstream CLI provisions them in one flow. Run these from your project directory.

1. Install the CLI (skip if you did this in Path A):

bash
1
curl -fsSL https://getstream.io/cli.sh | bash

2. Initialize the project. This one command authenticates you, lets you create or select an organization and app, and writes the project credentials. New to Stream? The login flow creates your organization. Already have an org or an app? It lets you pick them.

bash
1
getstream init

🧑 Human checkpoint: getstream init opens a browser to authenticate. Agents: run it, then ask the human to finish logging in before continuing. It's required first — env, token, and api commands fail with "stream project is not initialized" until it runs.

3. Write your API key into the Android project. This writes STREAM_API_KEY into local.properties (which is git-ignored by default) instead of hardcoding it in source. The API secret is never printed or written into the app. You wire it into BuildConfig in Step 2.

bash
1
getstream env --target android

4. Mint a user token for a user in your app (never-expiring by default; add a TTL for production-like testing):

bash
1
2
getstream token tutorial_user getstream token tutorial_user --ttl 1d

5. (Optional) Seed a channel so your first launch isn't an empty list. Create the users first, then the channel. The token user must be a member, or the list renders empty on first launch:

bash
1
2
3
4
getstream api UpdateUsers --request '{"users":{"tutorial_user":{"id":"tutorial_user","name":"Tutorial User"},"alice":{"id":"alice","name":"Alice"}}}' getstream api GetOrCreateChannel --type messaging --id general \ --request '{"data":{"created_by_id":"tutorial_user","members":[{"user_id":"tutorial_user"},{"user_id":"alice"}]}}'

Checkpoint: local.properties contains a STREAM_API_KEY=... line, and you have a token printed by the CLI, both belonging to your own app. Keep the token for Step 3.

Fallback: tutorial demo credentials

Want to skip account setup entirely? These work against Stream's shared, pre-seeded tutorial environment:

  • API keyuun7ywwamhs9
  • User IDtutorial-droid
  • TokeneyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidHV0b3JpYWwtZHJvaWQifQ.WwfBzU1GZr0brt_fXnqKdKhz3oj0rbDUm2DqJO_SS5U

Swap in your own credentials from the CLI flow above before building anything real.

Step 1 — Create the project

In Android Studio: File → New → New Project → Empty Views Activity.

  • Name the project ChatTutorial
  • Package name com.example.chattutorial
  • Language: Kotlin
  • Minimum SDK: API 24 or higher

Set up demo messaging app in Android Studio

Make sure the app theme uses a Theme.MaterialComponents parent (check themes.xml). If you're on an older setup with Theme.AppCompat, switch to a Material theme or use a Bridge Theme.

🧑 Human checkpoint: the project is created in Android Studio's New Project wizard — it can't be scaffolded from the CLI. Agents: detect the project shape first (find . -maxdepth 3 -name "settings.gradle*" -o -name "build.gradle*"). If there's no Gradle project, stop and ask the human to create it before continuing — don't scaffold it yourself.

Checkpoint:

bash
1
./gradlew :app:assembleDebug

Step 2 — Add the SDK dependency and wire the API key

The SDK is on Maven Central. Confirm your settings.gradle includes mavenCentral():

groovy
1
2
3
4
5
6
7
dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() } }

In the app module's build.gradle, enable View Binding, set compileSdk to 35, add the dependencies, and wire STREAM_API_KEY from local.properties into BuildConfig. Replace <latest_version> with the latest release:

groovy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
android { namespace "com.example.chattutorial" compileSdk 35 defaultConfig { applicationId "com.example.chattutorial" minSdk 24 targetSdk 35 versionCode 1 versionName "1.0" // Read STREAM_API_KEY from local.properties (written by // `getstream env --target android`); empty string if not set. def streamProps = new Properties() def streamPropsFile = rootProject.file("local.properties") if (streamPropsFile.exists()) { streamPropsFile.withInputStream { streamProps.load(it) } } buildConfigField "String", "STREAM_API_KEY", "\"${streamProps.getProperty('STREAM_API_KEY', '')}\"" } buildFeatures { viewBinding true // Required on AGP 8+: BuildConfig is off by default. buildConfig true } compileOptions { sourceCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11 } kotlinOptions { jvmTarget = "11" } } dependencies { implementation("io.getstream:stream-chat-android-ui-components:<latest_version>") implementation("androidx.activity:activity-ktx:1.9.2") implementation("com.google.android.material:material:1.12.0") implementation("io.coil-kt.coil3:coil:3.1.0") }

Checkpoint: ./gradlew :app:assembleDebug succeeds and BuildConfig.STREAM_API_KEY resolves.

Step 3 — Get a working app

Two screens: MainActivity shows the channel list, ChannelActivity shows a single conversation. Each screen is a layout file plus an Activity.

Channel list

Replace activity_main.xml with a full-screen ChannelListView:

xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <io.getstream.chat.android.ui.feature.channels.list.ChannelListView android:id="@+id/channelListView" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>

Replace MainActivity.kt. It builds the client, connects the user, and binds the channel list. The API key comes from BuildConfig (Step 2), falling back to the demo key.

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package com.example.chattutorial import android.os.Bundle import android.widget.Toast import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import com.example.chattutorial.databinding.ActivityMainBinding import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.logger.ChatLogLevel import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.viewmodel.channels.ChannelListViewModel import io.getstream.chat.android.ui.viewmodel.channels.ChannelListViewModelFactory import io.getstream.chat.android.ui.viewmodel.channels.bindView class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) // API key from local.properties via BuildConfig (Step 2); demo key as fallback. val apiKey = BuildConfig.STREAM_API_KEY.ifEmpty { "uun7ywwamhs9" } // Build the client. Offline storage and state management are enabled by default. val client = ChatClient.Builder(apiKey, applicationContext) .logLevel(ChatLogLevel.ALL) // Set to NOTHING in production .build() // Development token from `getstream token`. In production, fetch the // token from your backend after login — never hardcode secrets. 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 { result -> result.onSuccess { // Show channels of type "messaging" where this user is a member. val filter = Filters.and( Filters.eq("type", "messaging"), Filters.`in`("members", listOf(user.id)), ) val factory = ChannelListViewModelFactory(filter, ChannelListViewModel.DEFAULT_SORT) val viewModel: ChannelListViewModel by viewModels { factory } // Loose coupling: bind the ViewModel to the view. viewModel.bindView(binding.channelListView, this) binding.channelListView.setChannelItemClickListener { channel -> startActivity(ChannelActivity.newIntent(this, channel)) } }.onError { error -> Toast.makeText(this, "Connect failed: ${error.message}", Toast.LENGTH_SHORT).show() } } } }

Conversation screen

Add activity_channel.xml with the header, message list, and composer:

xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <io.getstream.chat.android.ui.feature.messages.header.ChannelHeaderView android:id="@+id/channelHeaderView" android:layout_width="0dp" android:layout_height="wrap_content" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <io.getstream.chat.android.ui.feature.messages.list.MessageListView android:id="@+id/messageListView" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toTopOf="@+id/messageComposerView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/channelHeaderView" /> <io.getstream.chat.android.ui.feature.messages.composer.MessageComposerView android:id="@+id/messageComposerView" android:layout_width="0dp" android:layout_height="wrap_content" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>

Create ChannelActivity.kt. Three ViewModels — header, message list, composer — share one factory and bind to their views. Android Studio adds the <activity> to your manifest automatically when you create the class through the wizard.

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
package com.example.chattutorial import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.addCallback import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import com.example.chattutorial.databinding.ActivityChannelBinding import io.getstream.chat.android.models.Channel import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.MessageMode import io.getstream.chat.android.ui.viewmodel.messages.ChannelHeaderViewModel import io.getstream.chat.android.ui.viewmodel.messages.ChannelViewModelFactory import io.getstream.chat.android.ui.viewmodel.messages.MessageComposerViewModel import io.getstream.chat.android.ui.viewmodel.messages.MessageListViewModel import io.getstream.chat.android.ui.viewmodel.messages.bindView class ChannelActivity : AppCompatActivity() { private lateinit var binding: ActivityChannelBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityChannelBinding.inflate(layoutInflater) setContentView(binding.root) val cid = checkNotNull(intent.getStringExtra(CID_KEY)) { "Specifying a channel id is required when starting ChannelActivity" } // One factory, three loosely-coupled ViewModels — customize each individually. val factory = ChannelViewModelFactory(this, cid) val channelHeaderViewModel: ChannelHeaderViewModel by viewModels { factory } val messageListViewModel: MessageListViewModel by viewModels { factory } val messageComposerViewModel: MessageComposerViewModel by viewModels { factory } channelHeaderViewModel.bindView(binding.channelHeaderView, this) messageListViewModel.bindView(binding.messageListView, this) messageComposerViewModel.bindView(binding.messageComposerView, this) // Keep the header and composer in sync when entering/leaving a thread. messageListViewModel.mode.observe(this) { mode -> when (mode) { is MessageMode.MessageThread -> { channelHeaderViewModel.setActiveThread(mode.parentMessage) messageComposerViewModel.setMessageMode(MessageMode.MessageThread(mode.parentMessage)) } is MessageMode.Normal -> { channelHeaderViewModel.resetThread() messageComposerViewModel.leaveThread() } } } binding.messageListView.setMessageEditHandler { message -> messageComposerViewModel.performMessageAction(Edit(message)) } // Finish the screen when the message list asks to navigate up. messageListViewModel.state.observe(this) { state -> if (state is MessageListViewModel.State.NavigateUp) finish() } // Back button: let the list handle threads first, then navigate up. val backHandler = { messageListViewModel.onEvent(MessageListViewModel.Event.BackButtonPressed) } binding.channelHeaderView.setBackButtonClickListener(backHandler) onBackPressedDispatcher.addCallback(this) { backHandler() } } companion object { private const val CID_KEY = "key:cid" fun newIntent(context: Context, channel: Channel): Intent = Intent(context, ChannelActivity::class.java).putExtra(CID_KEY, channel.cid) } }

Checkpoint: build and run. You should see the channel list with no Connect failed toast. Tap a channel → the conversation opens. Send a message, long-press it for reactions, open a thread. Empty list but no error? Your user has no channels yet — seed one with the getstream api GetOrCreateChannel command from Step 0.

Message list chat interface for Kotlin on Android

Step 4 — Theme the app

The XML views expose styling attributes you set right in the layout. Restyle the message bubbles by adding streamUi* attributes to MessageListView in activity_channel.xml:

xml
1
2
3
4
5
6
7
8
9
10
11
12
<io.getstream.chat.android.ui.feature.messages.list.MessageListView android:id="@+id/messageListView" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toTopOf="@+id/messageComposerView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/channelHeaderView" app:streamUiMessageBackgroundColorMine="#70AF74" app:streamUiMessageBackgroundColorTheirs="#FFFFFF" app:streamUiMessageTextColorMine="#FFFFFF" app:streamUiMessageTextColorTheirs="#000000" />

Checkpoint: run the app and send a message — your own messages now render with a green background and white text.

Full attribute reference: MessageListView styling.

Customize further — cookbooks

The XML components go much deeper. Each of these is a self-contained guide:

A complete sample with these customizations lives in the android-chat-tutorial repo.

Verify the whole build

Build and install to a running emulator or connected device:

bash
1
./gradlew :app:installDebug

🧑 Human checkpoint: launching the app on an emulator/device and confirming the UI is a human step. Agents: install with the command above, then hand back to the human to run it. Confirm the full loop: channel list loads → open a channel → send a message → long-press for reactions → open a thread.

Troubleshooting

  • stream project is not initialized — CLI onboarding not run. Run getstream init in the project directory first.
  • BuildConfig / STREAM_API_KEY unresolvedbuildConfig true missing under buildFeatures, or you didn't rebuild. Enable it (Step 2) and rerun ./gradlew :app:assembleDebug.
  • ActivityMainBinding / ActivityChannelBinding unresolved — View Binding not enabled. Add viewBinding true under buildFeatures (Step 2).
  • Views render unstyled or the app crashes inflating a layout — the app theme isn't a Material theme. Use a Theme.MaterialComponents parent (or a Bridge Theme).
  • Empty channel list, no error — the user has no channels. Seed one with getstream api GetOrCreateChannel (Step 0), and make sure the token user is in data.members.
  • token is invalid / auth error — token minted for a different app or expired. Re-mint with getstream token <user_id> and confirm the API key matches.
  • Toast "Connect failed"connectUser failed. Check Logcat; confirm the API key and token belong to the same app.

Next steps

Pricing is flexible with multiple tiers, including a free maker plan for hobby projects and small companies.

Final Thoughts

You've built a fully functional Android chat app with Kotlin and the XML UI components — channel list, conversation screen, reactions, threads, typing indicators, and offline support — then themed it. The Android Chat SDK is two libraries: stream-chat-android-client (low-level, state, offline) and stream-chat-android-ui-components (ready-made views). Mix and match them to fit your app.

Machine-readable resources

For AI agents and coding assistants working with this SDK:

  • CLI + skills: curl -fsSL https://getstream.io/cli.sh | bash, then getstream skills --universal and getstream skills stream-android --universal for the Android pack (use --claude instead for Claude Code). (Alternative: npx skills add GetStream/agent-skills -s stream.)
  • Provisioning: getstream init (auth + create/select org & app) → getstream env --target android (API key to local.properties) → getstream token <user_id> (mint a token)
  • Data & config from the CLI: getstream api <Endpoint> --request '{...}' for users, channels, and messages
  • Android integration skill: invoke /stream-android in your agent for Compose and XML setup patterns; /stream-docs searches live SDK docs with citations
  • Docs index for LLMs: https://getstream.io/chat/docs/sdk/android/llms.txt
  • Markdown endpoints: append .md to any docs URL for a clean, token-efficient version
  • Source of truth for APIs: the SDK repository — check the pinned version's source rather than assuming APIs from training data

Give us feedback!

Did you find this tutorial helpful in getting you up and running with your project? Either good or bad, we're looking for your honest feedback so we can improve.

Start coding for free

No credit card required.
If you're interested in a custom plan or have any questions, please contact us.