
Stream Android
- 364 installs
- 17 repo stars
- Updated August 4, 2026
- getstream/agent-skills
stream-android is a Claude Code skill that embeds Stream chat, activity feeds, or video SDK features into Android apps with correct client setup, channels, and UI integration patterns for developers building real-time mo
About
stream-android is a Claude Code skill for integrating GetStream’s Android SDKs—chat, activity feeds, and video—into Kotlin or Java Android applications. The skill guides developers through Stream client initialization, channel and user configuration, and UI component integration so messaging or feed features ship with correct SDK patterns. Developers reach for stream-android when adding real-time chat, social activity feeds, or video calling to an Android app without re-reading Stream’s Android documentation from scratch. The skill targets mobile engineers building consumer or team collaboration apps that depend on Stream’s hosted real-time infrastructure.
- Stream Android SDK setup and configuration
- Channel, user, and token integration patterns
- Chat or feed UI wiring on Android
- Pairs with backend auth for Stream tokens
Stream Android by the numbers
- 364 all-time installs (skills.sh)
- Ranked #348 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/getstream/agent-skills --skill stream-androidAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 364 |
|---|---|
| repo stars | ★ 17 |
| Last updated | August 4, 2026 |
| Repository | getstream/agent-skills ↗ |
How do you integrate Stream chat SDK into Android?
Embed Stream chat, activity feeds, or video SDK features into an Android app with correct client setup, channels, and UI integration patterns.
Who is it for?
Android developers adding Stream-powered chat, activity feeds, or video features to Kotlin or Java mobile apps.
Skip if: iOS or web Stream integrations, or Android apps that do not use GetStream’s real-time SDKs.
When should I use this skill?
A developer asks to add Stream chat, feeds, or video to an Android app, configure Stream channels, or wire Stream UI components.
What you get
Stream Android client configuration, channel setup, and UI-integrated chat, feed, or video components.
- Stream client initialization
- Channel configuration
- UI-integrated Stream components
Files
Stream Android - skill router + execution flow
Rules: Read [`RULES.md`](RULES.md) once per session - every non-negotiable rule is stated there, nowhere else.
This file is the single entrypoint: intent classification, local project detection, and module pointers for Stream work in Android apps.
---
Step 0: Intent classifier (mandatory first - never skip)
Before any tool call, decide the track from the user's input alone - no probes first.
Signals -> track
| Signal in user input | Track |
|---|---|
Explicit product/framework token: Chat Compose, Chat XML, Video Android, Video Compose, Feeds Android, Feeds Compose, etc. | C - Reference lookup |
| Words "docs" or "documentation" around Stream Android/Compose work | C - Reference lookup |
| "How do I {X} in Compose/XML/Android?", "What does {SDK type/Composable/View/Fragment} do?" | C - Reference lookup |
| "Build me a new Android app", "create a Compose app", "new Android app" + Stream product | A - New app |
| "Add/integrate Stream into this app", "wire Chat/Video/Feeds into my Android project" | B - Existing app |
| "Install Stream packages", "set up Stream in Android Studio", "wire auth/token flow" with no broader feature request | D - Bootstrap / setup |
Bare /stream-android with no args | List the tracks briefly and wait |
Disambiguation flow
If the request is ambiguous between build/integrate and reference lookup, ask one short question and wait:
Do you want me to wire this into the project, or just map the Android SDK pattern and files?
After classification
- Tracks A, B, D -> run Step 0.5 (credentials) first, then Project signals once per session, then continue in `builder.md` and `sdk.md`. Do not probe the project before credentials.
- Track C -> skip both steps if the product + UI layer are explicit. Only run Project signals on demand if the SDK or UI layer is ambiguous.
---
Step 0.5: Credentials, token, and seed data (tracks A, B, D only)
Order: intent classification -> Step 0.5 (this step) -> Project signals probe -> track work. Do this before running the Project signals shell command, even if a track table below lists "Detect" as phase 1. Run once per session for tracks A, B, and D. Skip for Track C.
Follow `credentials.md` to:
- collect the Stream API key from the dashboard (or from the user)
- generate a user token via the Stream CLI (or accept one from the user)
- run any product-specific setup (Chat: optionally seed channels; Feeds: confirm feed groups; Video: nothing — calls are ephemeral)
Wire the API key from getstream env's output (e.g. BuildConfig.STREAM_API_KEY) and use the real user token in code snippets - never placeholder strings. If a track A/B/D task reaches code work and credentials haven't been collected yet, return to credentials.md before continuing.
---
Project signals (tracks A/B/D - once per session; Track C on demand only)
Read-only local probe. Use it to detect whether the user is in an Android Studio / Gradle project, a Kotlin module, or an empty directory.
bash -c 'echo "=== GRADLE ROOT ==="; find . -maxdepth 2 \( -name "settings.gradle.kts" -o -name "settings.gradle" -o -name "build.gradle.kts" -o -name "build.gradle" \) -print 2>/dev/null; echo "=== APP MODULES ==="; find . -maxdepth 3 -type f \( -name "build.gradle.kts" -o -name "build.gradle" \) -path "*/*/build.gradle*" -print 2>/dev/null; echo "=== VERSION CATALOG ==="; find . -maxdepth 3 -name "libs.versions.toml" -print 2>/dev/null; echo "=== MANIFESTS ==="; find . -maxdepth 4 -name "AndroidManifest.xml" -print 2>/dev/null; echo "=== EMPTY ==="; test -z "$(ls -A 2>/dev/null)" && echo "EMPTY_CWD" || echo "NON_EMPTY"'Hold the result in conversation context. Don't re-run it unless the user changes directory or the project shape clearly changed.
Use the result to produce a one-line status, for example:
Compose app detected - app/build.gradle.kts - libs.versions.toml present - ready for Stream wiringMulti-module Gradle project detected - preserve existing module layoutXML / View-based app detected - keep current UI layer unless the user asks to migrateNo Gradle project found - user needs to create the app in Android Studio first
---
Module map
| Track | Module(s) |
|---|---|
| A - New app | `builder.md` + `sdk.md` + relevant reference files |
| B - Existing app | `builder.md` + `sdk.md` + relevant reference files |
| C - Reference lookup | `sdk.md` + relevant reference files |
| D - Bootstrap / setup | `builder.md` + `sdk.md` |
---
Reference layout
Shared Android/Kotlin patterns live in [`sdk.md`](sdk.md).
Product and UI-layer specifics live under `references/` using a flat naming scheme that can grow with the full Stream Android surface:
- Reference:
references/<PRODUCT>-<UI_LAYER>.md - Blueprints:
references/<PRODUCT>-<UI_LAYER>-blueprints.md
Current extracted modules:
- Chat + Compose: `references/CHAT-COMPOSE.md` + `references/CHAT-COMPOSE-blueprints.md`
- Chat + XML: `references/CHAT-XML.md` + `references/CHAT-XML-blueprints.md`
- Video + Compose: `references/VIDEO-COMPOSE.md` + `references/VIDEO-COMPOSE-blueprints.md`
- Feeds + Compose: `references/FEEDS-COMPOSE.md` + `references/FEEDS-COMPOSE-blueprints.md`
Feeds has no pre-built UI components.FEEDS-COMPOSE.mdcovers the headless data SDK (FeedsClient, FeedState, ActivityState);FEEDS-COMPOSE-blueprints.mdis custom Composable scaffolding driven by those state flows. Load both for any Feeds request.
Future Android product coverage should stay in this naming family instead of creating more top-level skills.
If the requested product/UI layer file is not bundled yet, say so plainly, use sdk.md for the shared Android patterns, and only switch to live docs if the user asks.
---
Track A - New app
Full detail: `builder.md` - use the new-project path.
| Phase | Name | What you do |
|---|---|---|
| A1 | Detect | After Step 0.5 (credentials), run Project signals. If there is no Android app yet, tell the user to create one in Android Studio first. |
| A2 | Choose lane | Confirm product(s) and UI layer: Compose, XML/Views, or mixed. |
| A3 | Install + wire | Follow `builder.md` + `sdk.md`, then load only the needed product references. |
| A4 | Verify | Confirm Gradle sync, ChatClient lifetime, auth, and first rendered screen. |
---
Track B - Existing app
Full detail: `builder.md` - use the existing-project path.
| Phase | Name | What you do |
|---|---|---|
| B1 | Detect | After Step 0.5 (credentials), run Project signals and inspect the existing app structure before editing. |
| B2 | Preserve | Keep the current UI layer, dependency strategy (version catalog vs inline), and navigation setup unless the user asks for a migration. |
| B3 | Integrate | Use `sdk.md` for shared wiring, then load only the needed product reference files. |
| B4 | Verify | Confirm the requested Stream flow builds and renders inside the existing app. |
---
Track C - Reference lookup
Load only the relevant files for the requested product and UI layer.
- Shared lifecycle / auth / state patterns -> `sdk.md`
- Chat Compose setup and gotchas -> `references/CHAT-COMPOSE.md`
- Chat Compose screen structure -> `references/CHAT-COMPOSE-blueprints.md`
- Chat XML setup and gotchas -> `references/CHAT-XML.md`
- Chat XML screen structure -> `references/CHAT-XML-blueprints.md`
- Video Compose setup and gotchas -> `references/VIDEO-COMPOSE.md`
- Video Compose call/screen structure -> `references/VIDEO-COMPOSE-blueprints.md`
- Feeds Compose SDK patterns -> `references/FEEDS-COMPOSE.md`
- Feeds Compose blueprints -> `references/FEEDS-COMPOSE-blueprints.md`
If the user asks for a product/UI-layer combo that is not bundled (e.g. Video XML, Feeds XML), say that clearly instead of inventing API details.
---
Track D - Bootstrap / setup
Use when the user wants the install and wiring path more than a feature build:
- run Step 0.5 (credentials) first
- detect the project shape
- choose Compose vs XML ownership
- install Stream packages with the project's existing dependency strategy (version catalog or inline)
- wire auth and
ChatClientlifetime via `sdk.md` - stop before product-specific UI if the user only asked for setup
Stream Android - build and integration flow
Use this module after intent classification and, when needed, the local Project signals probe from `SKILL.md`.
---
1. Detect the workspace
Start by understanding what kind of Android project is in front of you:
settings.gradle.kts/settings.gradleat the root -> existing Gradle projectapp/build.gradle.kts(or any*/build.gradle.ktswithcom.android.application) -> the app modulegradle/libs.versions.toml-> a version catalog is in use; add Stream entries thereAndroidManifest.xmlunder a module'ssrc/main/-> confirms an Android module- no Gradle files and
EMPTY_CWD-> tell the user to create a new Android app in Android Studio first
Inspect the app module's build.gradle.kts for existing Compose / View System usage before choosing a UI lane.
Do not try to scaffold Android Studio projects from the CLI.
---
2. Choose the integration lane
Resolve three things before editing:
1. Product: Chat, Video, Feeds, or a combination 2. UI layer: Jetpack Compose, XML / Views, or mixed 3. Scope: app bootstrap, auth, a specific screen, or a full product shell
If the user only asked for setup, stop after the shared wiring in `sdk.md`.
---
3. Install the SDKs
Prefer the project's existing dependency strategy:
- Version catalog (`gradle/libs.versions.toml`) present: add Stream entries to the catalog, then reference them from the app module's
build.gradle.kts. - No version catalog: add the dependency directly to the module's
build.gradle.kts(orbuild.gradle) underdependencies { ... }.
For the exact artifact ids, see the matching reference file (CHAT-COMPOSE.md, VIDEO-COMPOSE.md, FEEDS-COMPOSE.md, …).
When you need the current artifact version, follow `RULES.md` → Version lookup. Never query search.maven.org — its index is stale.
After editing, sync Gradle (Android Studio "Sync Now" or ./gradlew help) and confirm the dependency resolves before continuing.
Install only the artifacts needed for the requested Stream products.
---
4. Wire the shared app setup
Before writing any code, confirm that the credentials flow in `credentials.md` has completed — API key, token, and optional seed channels should already be in context. If not, run it now before continuing.
Follow `sdk.md` for:
- Stream client lifetime in an app-scoped owner (
Application, Hilt@Singleton, Koinsingle) - auth and token transport — reference credentials via named constants (e.g.,
Config.apiKey,Config.userTokenfrom a local config file), never embed raw credential values inline - ViewModel ownership and main-dispatcher boundaries
- user switching and session teardown
If seed channels were created during the credentials flow, the app should render them on first launch without any extra setup — no additional sample data or hardcoded channel IDs needed in the code.
Keep the existing app shell intact. Add the minimum composition points needed for Stream (typically: Application subclass registered in the manifest, one host Activity that sets up ChatTheme { ... }, and the requested screen Composable).
---
5. Load only the needed reference files
Use the product + UI layer to choose the smallest relevant reference set.
Available extracted modules:
- Chat + Compose: `references/CHAT-COMPOSE.md`
- Chat + Compose screen blueprints: `references/CHAT-COMPOSE-blueprints.md`
- Chat + XML: `references/CHAT-XML.md`
- Chat + XML screen blueprints: `references/CHAT-XML-blueprints.md`
- Video + Compose: `references/VIDEO-COMPOSE.md`
- Video + Compose call/screen blueprints: `references/VIDEO-COMPOSE-blueprints.md`
- Feeds + Compose: `references/FEEDS-COMPOSE.md`
- Feeds + Compose screen blueprints: `references/FEEDS-COMPOSE-blueprints.md`
Per `RULES.md` → Blueprints are mandatory, on every turn: every Stream Chat, Stream Video, or Stream Feeds screen, Composable, navigation handler, deep-link route, ringing flow, or UI customization must be preceded by reading the matching section in the corresponding blueprints file. Use the Request → Blueprint section table at the top of each *-blueprints.md file to pick the section. This applies to follow-up requests in the same session too — re-open the file and re-read the matching section before each Stream screen edit, do not rely on what was loaded earlier.
If the exact file is not present yet, say so directly instead of faking a reference.
---
6. Verify before you stop
Check the smallest set of outcomes that proves the integration works:
- Gradle sync succeeds and the Stream artifact resolves
ChatClient,StreamVideo, and/orFeedsClientare initialized fromApplication.onCreate()(or an owned DI binding) before any Stream Composable renders- the app does not call
ChatClient.instance()/StreamVideo.instance()before the corresponding builder has run; for Feeds,FeedsClientis owned (no global singleton) — never reach for aninstance()shape - for Feeds:
feedsClient.connect()has been awaited before anyfeed.getOrCreate()/addActivity(...)call - for Video:
CAMERAandRECORD_AUDIOare requested at runtime before the firstcall.join(...)(viaLaunchCallPermissions(call)orrememberCallPermissionsState(call)) - the requested login, channel list, channel, call, or feed surface appears where expected
- switching users does not leave a previous WebSocket connection or persisted state behind: Chat
disconnect()completes before the nextconnectUser; VideoStreamVideo.instance().logOut()andStreamVideo.removeClient()complete before the nextStreamVideoBuilder(...).build(); Feedsclient.disconnect()completes before constructing a freshFeedsClientfor the next user
Stream Android - credentials, token, and seed data
Run this once per session for tracks A, B, and D, right after intent classification and before the Project signals probe. Track C (reference lookup) does not need this — return to `SKILL.md` instead.
Goal
Collect the Stream API key, a user token, and any product-specific setup (Chat: optionally seed channels; Feeds: confirm feed groups; Video: nothing extra — calls are ephemeral) — all before touching code — so the app has something real to show from the first run.
This skill uses the `getstream` CLI (binary name getstream). It is the same binary used by `skills/stream`. Do not confuse it with the stream-cli binary from GetStream/stream-cli on GitHub - the command surface is different.
Single upfront question (ask exactly once, then act immediately)
Post one message asking all relevant things together. Do not split into multiple rounds.
For Chat projects:
To wire everything up with real data, I need a few quick answers:
>
1. Credentials — Should I fetch your API key via the Stream CLI and generate a token, or will you paste them yourself?
2. Token expiry — If I'm generating the token: should it expire? (e.g.1h,1d,30m) or never expire?
3. Seed channels — Should I pre-create a few channels with random usernames so the app has something to show immediately?
>
If you want to handle everything yourself, just paste your API key and token and tell me whether to seed channels.
For Feeds projects (no channel seeding — feed groups are configured in the Stream dashboard):
To wire everything up with real data, I need a couple of quick answers:
>
1. Credentials — Should I fetch your API key via the Stream CLI and generate a token, or will you paste them yourself?
2. Token expiry — If I'm generating the token: should it expire? (e.g.1h,1d,30m) or never expire?
3. Feed groups — What feed groups do you need? (defaults:user,timeline,notification— tell me if you want different names)
>
If you want to handle everything yourself, just paste your API key and token and confirm the feed group names.
For Video projects (calls are ephemeral — no seeding needed):
To wire everything up, I need a couple of quick answers:
>
1. Credentials — Should I fetch your API key via the Stream CLI and generate a token, or will you paste them yourself?
2. Token expiry — If I'm generating the token: should it expire? (e.g.1h,1d,30m) or never expire?
>
If you want to handle everything yourself, just paste your API key and token.
After the user replies — act without further prompting
Once the user answers, execute all CLI steps in sequence without pausing for confirmation between them. Narrate each step briefly as you go (one line per action), but do not stop to ask "shall I continue?". Channel seeding is mutating; the user's "yes" upfront covers the consent for those calls.
Step A0 - Confirm the CLI (mandatory)
The binary is named `getstream`. Detect it before any other CLI step:
command -v getstreamIf getstream does not resolve, ask the user to install it from https://getstream.io and wait - never fetch or run an install script yourself.
Authentication and app selection happen in Step A below: run the command and follow its output.
Step A - API key
getstream env --target androidgetstream env writes the app's public API key to local.properties (STREAM_API_KEY) and prints the wiring steps - expose it via buildConfigField in the module build.gradle and read BuildConfig.STREAM_API_KEY. Follow those steps; you don't need to hold the key yourself, and the secret is never written for Android.
If getstream env reports the project isn't initialized or you're not signed in, run getstream init, then re-run getstream env --target android.
Step B - Token
getstream token accepts a TTL as a duration string (30s, 2h, 1d); no need to compute an epoch. Omit --ttl for a never-expiring token.
# Never-expiring
getstream token <user_id>
# Expiring (use the user's requested duration verbatim, e.g. 1h, 30m, 1d)
getstream token <user_id> --ttl 1hHold the token in context. In generated code, read the API key from BuildConfig.STREAM_API_KEY (written by getstream env) and reference the token via a named constant (e.g. in Config.kt) - do not hardcode the secret.
Step C — Seed channels (Chat projects only; only if the user said yes)
Skip this step entirely for Feeds and Video projects. Feeds groups are configured on the Stream dashboard (not via the CLI), and Video calls are ephemeral — neither needs CLI-side seeding. Move directly to Step D after Steps A and B.
Create 3–5 channels with random realistic usernames. Use messaging as the channel type. The token user must end up as a member of at least one channel — otherwise the channel list will render empty on first launch even though the seed succeeded. created_by_id records who created the channel; it does not add that user to the members list. Membership is a separate concept and must be set explicitly.
These calls are mutating. The user's upfront "yes" covers the consent. Announce briefly: "Seeding channels (mutating operations)..." before the first call.
C1 — Create user records (once, upfront)
User records must exist before they can be added to a channel; otherwise GetOrCreateChannel rejects the call with users ... don't exist. Create the token user and all seed members in a single UpdateUsers batch:
getstream api UpdateUsers --request '{"users":{"<token_user_id>":{"id":"<token_user_id>","name":"Token User"},"alice":{"id":"alice","name":"Alice"},"bob":{"id":"bob","name":"Bob"},"carol":{"id":"carol","name":"Carol"}}}'Pick a small set of random realistic usernames (e.g. alice, bob, carol, dave, eve) and include the token user id explicitly.
C2 — Create each channel with members in one call
data.members accepts an array of {"user_id": "..."} objects (not bare strings) and adds them as channel members during creation. Top-level members on this endpoint is a pagination shape — do not use it for membership. Include the token user explicitly.
getstream api GetOrCreateChannel --type messaging --id <channel-id> \
--request '{"data":{"name":"<display name>","created_by_id":"<token_user_id>","members":[{"user_id":"<token_user_id>"},{"user_id":"alice"},{"user_id":"bob"}]}}'Generate short memorable channel IDs (e.g. general, random, team-alpha). Make sure the token user id appears in data.members for at least one channel — otherwise the channel list renders empty on first launch.
If a call fails with a parameter error, fall back to getstream api GetOrCreateChannel -h to confirm the exact signature, then retry.
After seeding, print a brief summary:
Created channels:general(<token_user_id>, alice, bob),random(<token_user_id>, carol, dave),team-alpha(<token_user_id>, alice, eve)
Step D — Proceed automatically
After all CLI steps succeed, return to `SKILL.md` → Project signals, then continue into `builder.md` — no additional prompt needed. If any CLI step fails, explain the error briefly and ask the user to paste the missing value manually before continuing.
What NOT to do
- Never put the API secret in app code — the CLI uses it server-side only.
- Never invent or fabricate credentials.
- Never ask "should I continue?" between Step A, B, C, and D - execute the whole sequence once the user's upfront answers are in.
- Never use
stream-cli(the public Go CLI fromGetStream/stream-cli) commands here - that is a different binary with a different command surface (stream-cli chat get-app,stream-cli chat create-token, etc.). This skill targets thegetstreambinary only.
Chat Compose - Screen Blueprints
Load only the section you are implementing. For setup, client initialization, and gotchas, see CHAT-COMPOSE.md.
Per `RULES.md` → Blueprints are mandatory, on every turn: any Stream Chat screen, Composable, navigation handler, deep-link route, or UI customization must be preceded by reading the matching section below — including on follow-up turns inside an existing session.
---
Request → Blueprint section
Use this table to resolve a user request to the section(s) you must read before writing code. If multiple rows match, read all of them. If none match, say so explicitly instead of improvising.
| User request signal | Section(s) to read |
|---|---|
"set up Stream", "initialize ChatClient", Application class, manifest wiring | Application Class Blueprint |
"login screen", "connect user", connectUser, token wiring | Login / Connect User Blueprint |
| "navigate between screens", "skip login if connected", auto-reconnect, app entry, root host | Root Navigation Blueprint |
"channel list", "channels screen", ChannelsScreen, ChannelList, channel filters/sort | Channel List Blueprint |
| "channel list header", custom top bar above channels | Custom Channel List Header Blueprint |
"channel screen", "message list", "open a channel", ChannelScreen, MessagesScreen, message composer | Channel (Message List) Blueprint |
| "navigate to channel", "open channel on tap", "tap a channel", channel click handler, route from list to messages | Channel Tap Handling / Deep-link Blueprint + Channel (Message List) Blueprint |
"deep link", push notification → channel, intent extras for cid | Channel Tap Handling / Deep-link Blueprint |
| "theme", colors, typography, dark mode, branding | Custom ChatTheme Blueprint |
| "custom channel item", channel row layout, avatar/preview override | Custom Channel Item Blueprint |
| "custom channel header", per-channel top bar, message-list header | Custom Channel Header Blueprint |
| state flows, observing channels/messages outside the bundled screens | State Layer Compose Blueprint |
If the request is something not covered (Video, Feeds, XML, or a Compose surface not listed above), do not fabricate APIs — say the blueprint is not bundled and fall back per `RULES.md`.
---
Application Class Blueprint
Build ChatClient once in Application.onCreate(). The Builder registers a singleton; retrieve it elsewhere via ChatClient.instance().
package com.example.streamchat
import android.app.Application
import android.content.pm.ApplicationInfo
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.client.logger.ChatLogLevel
class App : Application() {
override fun onCreate() {
super.onCreate()
val logLevel = if ((applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) ChatLogLevel.ALL else ChatLogLevel.NOTHING
ChatClient.Builder("your_api_key", applicationContext)
.logLevel(logLevel)
.build()
}
}Register it in AndroidManifest.xml:
<application
android:name=".App"
android:label="@string/app_name">
<!-- activities ... -->
</application>Wiring:
Application.onCreate()runs before any Activity, soChatClient.instance()is safe to call from any Composable lifecycle.- The Compose artifact transitively pulls in offline and state management; no extra Builder calls are required for default state-layer behavior.
---
Login / Connect User Blueprint
Show a login screen before connecting. Invoke connectUser once per user session, not on every Composable entry.
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.compose.ui.theme.ChatTheme
import io.getstream.chat.android.models.User
import io.getstream.result.Result
class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ChatTheme {
LoginScreen(
onConnected = {
startActivity(Intent(this, ChannelsActivity::class.java))
finish()
},
)
}
}
}
}
@Composable
fun LoginScreen(onConnected: () -> Unit) {
var userId by remember { mutableStateOf("") }
var name by remember { mutableStateOf("") }
var isConnecting by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Sign In", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(16.dp))
OutlinedTextField(value = userId, onValueChange = { userId = it }, label = { Text("User ID") })
Spacer(Modifier.height(8.dp))
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Display name") })
error?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colorScheme.error)
}
Spacer(Modifier.height(16.dp))
Button(
enabled = userId.isNotBlank() && !isConnecting,
onClick = {
isConnecting = true
error = null
val user = User(id = userId, name = name.ifBlank { userId })
// Demo wiring only. In production, fetch the token from your backend and pass
// a TokenProvider to connectUser instead of a static string.
ChatClient.instance()
.connectUser(user, token = "your_static_token_here")
.enqueue { result ->
isConnecting = false
when (result) {
is Result.Success -> onConnected()
is Result.Failure -> error = result.value.message
}
}
},
) {
if (isConnecting) CircularProgressIndicator(strokeWidth = 2.dp) else Text("Connect")
}
}
}Wiring:
connectUser(...).enqueue { ... }already hops back to the main thread for the callback - state updates are safe to write directly.- For an expiring token, swap the
token = "..."argument for aTokenProvider(see CHAT-COMPOSE.md - User Authentication). - Auto-reconnect across app launches: check
ChatClient.instance().getCurrentUser() != nullbefore showing the login screen.
---
Root Navigation Blueprint
Gate the app on connection state. Skips login if a previous session is still connected.
@Composable
fun RootScreen() {
val currentUser = ChatClient.instance().getCurrentUser()
var isConnected by remember { mutableStateOf(currentUser != null) }
if (isConnected) {
ChannelsScreenHost(
onChannelClick = { /* navigate to ChannelScreen */ },
onLogout = {
ChatClient.instance().disconnect(flushPersistence = false).enqueue {
isConnected = false
}
},
)
} else {
LoginScreen(onConnected = { isConnected = true })
}
}Wiring:
ChatClient.instance().getCurrentUser()is non-null while a user is connected and survives process restarts when offline persistence is enabled (default in the Compose artifact).- For a single-Activity Compose project, host this inside a
NavHostand route to a separate channel-screen destination.
---
Channel List Blueprint
Drop-in ChannelsScreen
import io.getstream.chat.android.compose.ui.channels.ChannelsScreen
import io.getstream.chat.android.compose.ui.channels.SearchMode
@Composable
fun ChannelsScreenHost(
onChannelClick: (cid: String) -> Unit,
onLogout: () -> Unit,
) {
ChannelsScreen(
title = "Messages",
isShowingHeader = true,
searchMode = SearchMode.Channels,
onChannelClick = { channel -> onChannelClick(channel.cid) },
onHeaderAvatarClick = { onLogout() },
onBackPressed = { /* finish or pop */ },
)
}Custom filters and sort
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModelFactory
import io.getstream.chat.android.models.Filters
import io.getstream.chat.android.models.querysort.QuerySortByField
@Composable
fun FilteredChannelsScreen(onChannelClick: (cid: String) -> Unit) {
val currentUserId = ChatClient.instance().getCurrentUser()?.id ?: return
ChannelsScreen(
viewModelFactory = ChannelListViewModelFactory(
filters = Filters.and(
Filters.eq("type", "messaging"),
Filters.`in`("members", listOf(currentUserId)),
),
querySort = QuerySortByField.descByName("last_updated"),
channelLimit = 30,
),
title = "Stream Chat",
onChannelClick = { onChannelClick(it.cid) },
)
}Wiring:
ChannelsScreenalready includes its own header, search bar, list, and back-handling. Don't wrap it in anotherScaffold.- Set the title via the
titleparameter;isShowingHeader = falsehides the header entirely. searchModeacceptsSearchMode.None,SearchMode.Channels, orSearchMode.Messages.- Navigation between the channel list and a channel screen is not automatic - implement
onChannelClickand route to your channel destination.
Bound ChannelList (custom shell)
import androidx.lifecycle.viewmodel.compose.viewModel
import io.getstream.chat.android.compose.ui.channels.list.ChannelList
import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModel
import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModelFactory
@Composable
fun CustomChannelListShell(onChannelClick: (cid: String) -> Unit) {
val factory = ChannelListViewModelFactory(filters = null)
val viewModel: ChannelListViewModel = viewModel(factory = factory)
Scaffold(
topBar = { TopAppBar(title = { Text("My channels") }) },
) { padding ->
ChannelList(
modifier = Modifier.padding(padding),
viewModel = viewModel,
onChannelClick = { onChannelClick(it.cid) },
)
}
}---
Custom Channel List Header Blueprint
Use the SDK's ChannelListHeader Composable when you want a channel-list shell with the SDK's connection/avatar/title behavior but your own surrounding layout. It exposes currentUser, connectionState, title, plus optional leadingContent and trailingContent slots.
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Icon
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.compose.ui.channels.header.ChannelListHeader
import io.getstream.chat.android.compose.ui.theme.ChatTheme
@Composable
fun CustomChannelListHeader(onAddChannel: () -> Unit) {
val user by ChatClient.instance().clientState.user.collectAsState()
val connectionState by ChatClient.instance().clientState.connectionState.collectAsState()
ChannelListHeader(
modifier = Modifier.fillMaxWidth(),
currentUser = user,
title = "My Chat App",
connectionState = connectionState,
onAvatarClick = { _ -> /* open profile */ },
trailingContent = {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "New channel",
tint = ChatTheme.colors.textPrimary,
modifier = Modifier.clickable { onAddChannel() },
)
},
)
}Wiring:
ChatClient.instance().clientState.userand.connectionStateareStateFlows the SDK keeps in sync with the live socket - collect them withcollectAsState()(orcollectAsStateWithLifecycle()) instead of caching the values.ChannelListHeaderis also whatChannelsScreenrenders by default - reach for this Composable when you've dropped to a custom shell withChannelListand need the same header behavior.- For a fully custom title/back-button bar, replace
ChannelListHeaderwith your ownRow/TopAppBarand ignore this Composable entirely.
---
Channel (Message List) Blueprint
Drop-in ChannelScreen
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import io.getstream.chat.android.compose.ui.messages.ChannelScreen
import io.getstream.chat.android.compose.ui.theme.ChatTheme
import io.getstream.chat.android.compose.viewmodel.messages.ChannelViewModelFactory
import io.getstream.chat.android.compose.viewmodel.messages.MessageListOptions
class ChannelActivity : ComponentActivity() {
private val channelId: String
get() = intent.getStringExtra(KEY_CHANNEL_ID) ?: "messaging:general"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ChatTheme {
ChannelScreen(
viewModelFactory = ChannelViewModelFactory(
context = this,
channelId = channelId,
messageListOptions = MessageListOptions(
messageLimit = 30,
enforceUniqueReactions = true,
),
),
onBackPressed = { finish() },
onHeaderTitleClick = { /* show channel info */ },
)
}
}
}
companion object {
private const val KEY_CHANNEL_ID = "channelId"
fun createIntent(context: Context, cid: String) =
Intent(context, ChannelActivity::class.java).putExtra(KEY_CHANNEL_ID, cid)
}
}Overriding the ViewModels (custom screen layout)
import androidx.activity.viewModels
import io.getstream.chat.android.compose.viewmodel.messages.AttachmentsPickerViewModel
import io.getstream.chat.android.compose.viewmodel.messages.ChannelViewModelFactory
import io.getstream.chat.android.compose.viewmodel.messages.MessageComposerViewModel
import io.getstream.chat.android.compose.viewmodel.messages.MessageListViewModel
class CustomChannelActivity : ComponentActivity() {
private val factory by lazy {
ChannelViewModelFactory(context = this, channelId = "messaging:general")
}
private val listViewModel: MessageListViewModel by viewModels { factory }
private val composerViewModel: MessageComposerViewModel by viewModels { factory }
private val attachmentsPickerViewModel: AttachmentsPickerViewModel by viewModels { factory }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ChatTheme {
Column(Modifier.fillMaxSize()) {
MessageList(
modifier = Modifier.weight(1f),
viewModel = listViewModel,
)
MessageComposer(viewModel = composerViewModel)
}
}
}
}
}Wiring:
ChannelViewModelFactoryresolves all three ViewModels (MessageListViewModel,MessageComposerViewModel,AttachmentsPickerViewModel) from a single factory instance.MessageListOptionscontrols page size, reaction uniqueness, system-message visibility, thread direction.MessageComposer(viewModel = ...)already callscomposerViewModel.sendMessage(...)from its default send handler. Only passonSendMessage = { ... }when you need to inject extra behavior (analytics, validation, intercepting drafts) - reproducing the default call buys nothing.ChannelScreenalready wires the composer + attachments picker + message list together - drop down to bound components only if you need a non-standard layout.
---
Custom ChatTheme Blueprint
Build appearance once and apply at every Stream screen root. Match light/dark with isSystemInDarkTheme().
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import io.getstream.chat.android.compose.ui.theme.ChatTheme
import io.getstream.chat.android.compose.ui.theme.StreamDesign
@Composable
fun BrandChatTheme(content: @Composable () -> Unit) {
val baseColors = if (isSystemInDarkTheme()) {
StreamDesign.Colors.defaultDark()
} else {
StreamDesign.Colors.default()
}
val branded = baseColors.copy(
accentPrimary = Color(0xFF7C3AED),
)
ChatTheme(
colors = branded,
typography = StreamDesign.Typography.default(),
content = content,
)
}Wiring:
StreamDesign.Colors.default()/.defaultDark()return adata class- use.copy(...)to override only the tokens you want. Common overrides:accentPrimary,textPrimary,backgroundCoreApp,borderCoreDefault. Re-brand the entire UI in one shot viaStreamDesign.Colors.default(brand = StreamDesign.ColorScale.from(brandColor = ...))instead of overriding individual tokens.- Pass the same
BrandChatThemeat every Activity that hosts Stream content; nestingChatThemeinside anotherChatThemeis supported but redundant.
---
Custom Channel Item Blueprint
ChannelList does not take an itemContent lambda. Override the channel row via ChatComponentFactory.ChannelListItemContent(...) and pass your factory to ChatTheme(componentFactory = ...).
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.material3.Badge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.getstream.chat.android.client.extensions.currentUserUnreadCount
import io.getstream.chat.android.compose.state.channels.list.ItemState
import io.getstream.chat.android.compose.ui.theme.ChatComponentFactory
import io.getstream.chat.android.compose.ui.theme.ChannelListItemContentParams
import io.getstream.chat.android.compose.ui.theme.ChatTheme
class BrandComponentFactory : ChatComponentFactory {
@Composable
override fun LazyItemScope.ChannelListItemContent(params: ChannelListItemContentParams) {
val channel = params.channelItem.channel
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { params.onChannelClick(channel) }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = channel.name.ifBlank { channel.cid },
style = MaterialTheme.typography.titleMedium,
)
channel.messages.lastOrNull()?.let { last ->
Text(
text = last.text,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
)
}
}
val unread = channel.currentUserUnreadCount()
if (unread > 0) {
Badge { Text(unread.toString()) }
}
}
}
}
@Composable
fun BrandedChannelsHost(onChannelClick: (Channel) -> Unit) {
ChatTheme(componentFactory = BrandComponentFactory()) {
ChannelsScreen(onChannelClick = onChannelClick)
}
}Wiring:
ChannelListItemContentParamsexposeschannelItem: ItemState.ChannelItemState(withchannel,isMuted,typingUsers,draftMessage,isSelected), pluscurrentUser,onChannelClick,onChannelLongClick. For unread count, use the extensionchannel.currentUserUnreadCount()fromio.getstream.chat.android.client.extensions.- To override only a sub-piece of the row (avatar, name, trailing timestamp/unread), prefer
ChannelItemLeadingContent/ChannelItemCenterContent/ChannelItemTrailingContentinstead of replacing the whole row - they preserve the SDK swipe-action wrapper. - Composables overridden on
ChatComponentFactoryneed their original receiver (LazyItemScope,RowScope, etc.) - copy the receiver from the interface declaration, otherwise the override won't compile.
---
Custom Channel Header Blueprint
Two paths:
- Factory override — override
ChatComponentFactory.ChannelHeader(...)(or one of its sub-slots) and pass your factory toChatTheme(componentFactory = ...). EveryChannelScreencall inside that `ChatTheme` subtree picks it up. - `topBarContent` lambda — pass your own
topBarContent: @Composable (BackAction) -> UnittoChannelScreen(...). Bypasses the factory entirely for that call site, even when the enclosingChatThemehas a custom factory.
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import io.getstream.chat.android.compose.ui.theme.ChannelHeaderTrailingContentParams
import io.getstream.chat.android.compose.ui.theme.ChatComponentFactory
import io.getstream.chat.android.compose.ui.theme.ChatTheme
class BrandComponentFactory(
private val onCall: () -> Unit,
) : ChatComponentFactory {
@Composable
override fun RowScope.ChannelHeaderTrailingContent(params: ChannelHeaderTrailingContentParams) {
IconButton(onClick = onCall) {
Icon(Icons.Default.Call, contentDescription = "Call")
}
}
}
@Composable
fun BrandedChannelHost(
factory: ChannelViewModelFactory,
onBack: () -> Unit,
onCall: () -> Unit,
) {
ChatTheme(componentFactory = BrandComponentFactory(onCall = onCall)) {
ChannelScreen(viewModelFactory = factory, onBackPressed = onBack)
}
}Wiring:
- The factory has three sub-slots —
ChannelHeaderLeadingContent(back button),ChannelHeaderCenterContent(title + typing/connection state),ChannelHeaderTrailingContent(avatar). Override only the one you need; the others keep their SDK defaults. - To replace the whole bar (back + title + trailing as one Composable) override
ChatComponentFactory.ChannelHeader(params: ChannelHeaderParams)instead — you lose the row scaffold but gain full layout control. Or, for a one-off swap at a single call site, passtopBarContentdirectly toChannelScreen(...)without touching the factory. - Composables overridden on
ChatComponentFactoryneed their original receiver (RowScopefor these three slots) - copy the receiver from the interface declaration, otherwise the override won't compile.
---
Channel Tap Handling / Deep-link Blueprint
ChannelsScreen does not navigate on its own. Provide onChannelClick (and onSearchMessageItemClick when search is enabled) to route into your own destination. Deep-linking from a push notification means launching the channel destination directly with the cid you received.
Route a tap into your channel destination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@Composable
fun ChatNavGraph() {
val nav = rememberNavController()
NavHost(navController = nav, startDestination = "channels") {
composable("channels") {
ChatTheme {
ChannelsScreen(
title = "Messages",
onChannelClick = { channel ->
nav.navigate("channel/${channel.cid}")
},
onBackPressed = { /* finish or pop */ },
)
}
}
composable("channel/{cid}") { backStack ->
val cid = backStack.arguments?.getString("cid") ?: return@composable
ChatTheme {
ChannelScreen(
viewModelFactory = ChannelViewModelFactory(
context = LocalContext.current,
channelId = cid,
),
onBackPressed = { nav.popBackStack() },
)
}
}
}
}Intercept a tap without navigating
When you only want analytics, an action sheet, or a custom side-effect on tap, do the work inside onChannelClick and skip the nav.navigate(...) call:
ChannelsScreen(
onChannelClick = { channel ->
analytics.track("channel_tapped", mapOf("cid" to channel.cid))
// no navigation
},
)Deep-link from a push notification
Push payloads include the cid ("<type>:<id>") and an optional messageId. Launch the channel destination directly from the notification intent - skip the channel list:
class StartupActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val cid = intent.getStringExtra(EXTRA_CID)
setContent {
ChatTheme {
if (cid != null) {
ChannelScreen(
viewModelFactory = ChannelViewModelFactory(
context = this,
channelId = cid,
messageId = intent.getStringExtra(EXTRA_MESSAGE_ID),
),
onBackPressed = { finish() },
)
} else {
ChannelsScreenHost(
onChannelClick = { /* navigate to channel/<cid> */ },
onLogout = { /* ... */ },
)
}
}
}
}
companion object {
const val EXTRA_CID = "cid"
const val EXTRA_MESSAGE_ID = "messageId"
fun createIntent(context: Context, cid: String, messageId: String? = null) =
Intent(context, StartupActivity::class.java)
.putExtra(EXTRA_CID, cid)
.putExtra(EXTRA_MESSAGE_ID, messageId)
}
}Wiring:
Channel.cidis the canonical"<type>:<id>"string - pass it as a single route argument instead of two separate fields.ChannelViewModelFactoryaccepts an optionalmessageIdargument for jumping to a specific message inside the channel (used by push deep-links).ChannelsScreendoes not expose aselectedChannelIdparameter - if you want a master-detail layout that highlights the current channel, drop down to boundChannelList+ your own selection state.
---
State Layer Compose Blueprint
Use the state layer when you want StateFlow reads + suspend mutations instead of bound ViewModels.
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.client.api.state.watchChannelAsState
import io.getstream.chat.android.models.Message
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
class StateChannelViewModel(private val cid: String) : ViewModel() {
private val client = ChatClient.instance()
private val channelClient = client.channel(cid.substringBefore(":"), cid.substringAfter(":"))
@OptIn(ExperimentalCoroutinesApi::class)
val messages: StateFlow<List<Message>> =
client.watchChannelAsState(cid = cid, messageLimit = 30, coroutineScope = viewModelScope)
.filterNotNull()
.flatMapLatest { it.messages }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun send(text: String) {
channelClient.sendMessage(Message(text = text)).enqueue()
}
}@Composable
fun StateChannelScreen(cid: String) {
val viewModel: StateChannelViewModel = viewModel(factory = viewModelFactory {
initializer { StateChannelViewModel(cid) }
})
val messages by viewModel.messages.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize()) {
LazyColumn(Modifier.weight(1f)) {
items(messages, key = { it.id }) { message ->
Text(
text = message.text,
modifier = Modifier.fillMaxWidth().padding(8.dp),
)
}
}
var draft by remember { mutableStateOf("") }
Row(Modifier.padding(8.dp)) {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier.weight(1f),
)
Button(onClick = {
viewModel.send(draft)
draft = ""
}) { Text("Send") }
}
}
}Wiring:
watchChannelAsState(cid, messageLimit, scope)returnsStateFlow<ChannelState?>;ChannelState.messagesis itself aStateFlow<List<Message>>you can collect inside Compose.- Mutations (
sendMessage,sendReaction,markRead) still go through the regularChannelClientCallAPI. - For a list of channels, swap to
client.queryChannelsAsState(request, scope).
Chat - Compose SDK Setup & Integration
Stream Chat Compose provides pre-built Jetpack Compose components for building rich messaging UIs. This file covers Gradle setup, client setup, authentication, customization, and gotchas. For screen blueprints, see CHAT-COMPOSE-blueprints.md.
Rules: ../RULES.md (secrets, no dev tokens in production, proper logout).
- Blueprint - Compose screen structure and initialization
- Wiring - SDK calls for each component, exact property paths
- Requirements -
minSdk21+, Kotlin + Jetpack Compose enabled in the app module.
Quick ref
- Target version: Stream Chat Android v7.x (see RULES.md - if your memory disagrees with the docs, trust the docs)
- Artifact (Compose):
io.getstream:stream-chat-android-composevia Maven Central - Artifact (core only):
io.getstream:stream-chat-android-client(pulled in transitively by Compose) - First: Installation -> Manifest ->
ChatClientbuild ->connectUser->ChatTheme { ChannelsScreen() } - Per feature: Jump to the relevant section or blueprint when implementing a screen
- Docs: If you can't find an information here, check the docs:
https://getstream.io/chat/docs/sdk/android/compose/overview/
Full screen blueprints: CHAT-COMPOSE-blueprints.md - load only the section you are implementing.
---
App Integration
Installation (Gradle)
Check if the SDK is already installed in the project. If not:
With version catalog (`gradle/libs.versions.toml`):
[versions]
stream-chat-compose = "<latest>"
[libraries]
stream-chat-compose = { module = "io.getstream:stream-chat-android-compose", version.ref = "stream-chat-compose" }// app/build.gradle.kts
dependencies {
implementation(libs.stream.chat.compose)
}Without version catalog:
// app/build.gradle.kts
dependencies {
implementation("io.getstream:stream-chat-android-compose:<latest>")
}If you don't know the latest version, ask the user to check the installation guide.
Client Initialization
Initialize once in your Application class. Never create ChatClient in a @Composable body, a remember { ... } factory, or an Activity.onCreate that re-runs - the Builder registers a singleton.
import android.app.Application
import android.content.pm.ApplicationInfo
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.client.logger.ChatLogLevel
class App : Application() {
override fun onCreate() {
super.onCreate()
val logLevel = if ((applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) ChatLogLevel.ALL else ChatLogLevel.NOTHING
ChatClient.Builder("your_api_key", applicationContext)
.logLevel(logLevel)
.build()
}
}Register the Application subclass in AndroidManifest.xml:
<application
android:name=".App"
...>After build time, retrieve the client anywhere via ChatClient.instance(). The Builder registers the singleton automatically - do not store your own copy as a top-level lateinit var.
User Authentication
Default - hardcoded token (no expiry):
Ask the user for their Stream token:
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.models.User
import io.getstream.result.Result
val user = User(
id = "user-id",
name = "User Name",
image = "https://example.com/avatar.png",
)
ChatClient.instance()
.connectUser(user, token = "your_static_token_here")
.enqueue { result ->
when (result) {
is Result.Success -> { /* connected */ }
is Result.Failure -> { /* handle error */ }
}
}Token provider (expiring tokens):
Use this when the user has a backend endpoint that issues Stream JWTs. The provider is called automatically by the SDK when the token expires:
import io.getstream.chat.android.client.token.TokenProvider
val tokenProvider = object : TokenProvider {
override fun loadToken(): String {
// Synchronous call to your backend - blocks the calling thread.
// The SDK invokes this on a background dispatcher.
return yourAuthService.fetchStreamToken(userId = user.id)
}
}
ChatClient.instance()
.connectUser(user, tokenProvider)
.enqueue { /* ... */ }TokenProvider.loadToken() is synchronous - block on your backend call inside it; the SDK runs the provider off the main thread and re-invokes it whenever the token expires.
Disconnecting / switching users
For switching to another user, prefer ChatClient.switchUser(...) — it disconnects the current user, deletes the push device, and connects the new user atomically:
ChatClient.instance().switchUser(nextUser, nextToken).enqueue { result ->
when (result) {
is Result.Success -> { /* connected as next user */ }
is Result.Failure -> { /* handle error */ }
}
}For a full logout (no follow-up connectUser), call disconnect(flushPersistence = true) to clear the offline cache. If you do roll your own switch via disconnect(...).enqueue { connectUser(...) }, always wait for disconnect() to complete before connecting — connecting in flight risks state corruption.
Creating Channels
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.models.Channel
import io.getstream.result.Result
// Existing channel by id - call create() on the channel client.
val channelClient = ChatClient.instance().channel(channelType = "messaging", channelId = "general")
channelClient.create(memberIds = emptyList(), extraData = emptyMap()).enqueue { result ->
when (result) {
is Result.Success -> { val channel: Channel = result.value }
is Result.Failure -> { /* handle error */ }
}
}
// Distinct channel for a list of members - leave channelId empty so the backend
// derives a stable id from the member set.
ChatClient.instance().createChannel(
channelType = "messaging",
channelId = "",
memberIds = listOf("alice", "bob"),
extraData = emptyMap(),
).enqueue { /* ... */ }memberIds must include the user ids you want as channel members; pass emptyList() for an empty channel that you populate later. Pass channel-level fields (name, image, custom keys) via extraData (see Extra Data).
Showing the Channel List
Wrap any Stream Composable in ChatTheme { ... }. The drop-in ChannelsScreen renders the channel list, header, search, and navigation events:
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import io.getstream.chat.android.compose.ui.channels.ChannelsScreen
import io.getstream.chat.android.compose.ui.theme.ChatTheme
class ChannelsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ChatTheme {
ChannelsScreen(
onChannelClick = { channel ->
// navigate to ChannelActivity / ChannelScreen
},
onBackPressed = { finish() },
)
}
}
}
}---
Client Patterns
ViewModel Management
Create Stream ViewModels via `viewModels { factory }` - never inside Composables.
The Compose SDK ships some factories, like:
ChannelListViewModelFactory->ChannelListViewModelChannelViewModelFactory->MessageListViewModel,MessageComposerViewModel,AttachmentsPickerViewModel
import androidx.activity.viewModels
import io.getstream.chat.android.compose.viewmodel.messages.ChannelViewModelFactory
import io.getstream.chat.android.compose.viewmodel.messages.MessageListViewModel
import io.getstream.chat.android.compose.viewmodel.messages.MessageComposerViewModel
import io.getstream.chat.android.compose.viewmodel.messages.AttachmentsPickerViewModel
import io.getstream.chat.android.compose.viewmodel.messages.MessageListOptions
class ChannelActivity : ComponentActivity() {
private val factory by lazy {
ChannelViewModelFactory(
context = this,
channelId = "messaging:general",
messageListOptions = MessageListOptions(messageLimit = 30),
)
}
private val listViewModel: MessageListViewModel by viewModels { factory }
private val composerViewModel: MessageComposerViewModel by viewModels { factory }
private val attachmentsPickerViewModel: AttachmentsPickerViewModel by viewModels { factory }
}Pass the factory (or the resolved ViewModels) into the screen-level Composable. Drop-in screens like ChannelScreen(viewModelFactory = factory) resolve all three ViewModels from a single factory.
Bound vs stateless components
Stream Compose components come in three flavors:
- Screen components (
ChannelsScreen,ChannelScreen) - full screens with built-in ViewModels and navigation hooks - Bound components (
ChannelList,MessageList,MessageComposer) - take a ViewModel parameter - Stateless components (under
ui/components/) - take pure state and callbacks
Pick the highest-level component that still meets the customization need. Drop down only when you need to override behavior the screen doesn't expose.
Sub-piece customization (channel item rows, list headers, empty/loading states, message item parts, etc.) goes through `ChatComponentFactory`, not slot lambdas on the bound components. See Component factory customization below.
---
ChannelListViewModelFactory Options
import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModelFactory
import io.getstream.chat.android.models.Filters
import io.getstream.chat.android.models.querysort.QuerySortByField
val factory = ChannelListViewModelFactory(
filters = Filters.and(
Filters.eq("type", "messaging"),
Filters.`in`("members", listOf(currentUserId)),
),
querySort = QuerySortByField.descByName("last_updated"),
channelLimit = 30,
memberLimit = 30,
messageLimit = 1,
)filters = null falls back to the default query (Filters.in("members", listOf(currentUserId))). Use Filters.and(...), Filters.or(...), Filters.eq(...), Filters.in(...) to compose filter expressions.
---
State Layer (coroutines API)
The state layer exposes channel/message data as StateFlows. Use it when you want suspend mutations and collectAsStateWithLifecycle() reads instead of bound ViewModels.
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.client.api.state.watchChannelAsState
val client = ChatClient.instance()
val channelState = client
.watchChannelAsState(cid = "messaging:general", messageLimit = 30, coroutineScope = viewModelScope)
// Compose:
val state by channelState.collectAsStateWithLifecycle()
val messages = state?.messages?.collectAsStateWithLifecycle()?.value.orEmpty()Common state-layer entry points:
client.watchChannelAsState(cid, messageLimit, scope)->StateFlow<ChannelState?>client.queryChannelsAsState(request, scope)->StateFlow<QueryChannelsState?>client.globalState->GlobalStatewithuser,totalUnreadCount,channelUnreadCount, etc.
Send actions through the regular ChatClient/ChannelClient Call API:
val channelClient = client.channel("messaging", "general")
channelClient.sendMessage(Message(text = "Hello!")).enqueue { /* ... */ }---
Extra Data
Attach arbitrary Map<String, Any> to users, messages, channels, and attachments via the extraData parameter on each model.
Set extra data:
import io.getstream.chat.android.models.Message
import io.getstream.chat.android.models.User
// On the current user
val user = User(
id = "alice",
name = "Alice",
image = "https://example.com/alice.png",
extraData = mutableMapOf(
"email" to "alice@example.com",
"isPremium" to true,
),
)
ChatClient.instance().connectUser(user, token).enqueue { /* ... */ }
// On a new message
val message = Message(
text = "Here's your ticket",
extraData = mutableMapOf(
"ticketId" to "abc-123",
"price" to 20.0,
),
)
ChatClient.instance()
.channel("messaging", "general")
.sendMessage(message)
.enqueue { /* ... */ }
// On a new channel
ChatClient.instance().createChannel(
channelType = "messaging",
channelId = "support",
memberIds = listOf("alice", "bob"),
extraData = mutableMapOf(
"name" to "Support",
"image" to "https://example.com/support.png",
"team" to "red",
),
).enqueue { /* ... */ }Read extra data:
val email = user.extraData["email"] as? String
val isPremium = user.extraData["isPremium"] as? Boolean ?: false
val ticketId = message.extraData["ticketId"] as? String
val price = (message.extraData["price"] as? Number)?.toDouble()
// Nested
val metadata = message.extraData["metadata"] as? Map<*, *>
val value = metadata?.get("key") as? StringextraData round-trips through JSON, so the values come back as String, Double/Long (numbers), Boolean, Map<String, Any?>, or List<Any?>. Cast defensively.
Clean-access extension pattern:
val User.email: String? get() = extraData["email"] as? String
val User.isPremium: Boolean get() = extraData["isPremium"] as? Boolean ?: falseChannel has top-level name and image properties that the SDK populates from extraData["name"] / extraData["image"] automatically - prefer the typed properties for those two keys.
---
Logging
Disabled by default. Enable on the Builder, ideally only in debug builds:
ChatClient.Builder(apiKey, context)
.logLevel(if ((context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) ChatLogLevel.ALL else ChatLogLevel.NOTHING)
.build()Levels: ChatLogLevel.ALL, DEBUG, WARN, ERROR, NOTHING.
For finer control, pass a ChatLoggerHandler via .loggerHandler(...) and bridge to your existing logger (Timber, etc.).
---
Customization
ChatTheme
ChatTheme is the entry point for all theming - colors, typography, formatters, image loaders, the component factory, etc. Build it once at the top of every Stream screen. By default ChatTheme() already picks light vs dark colors via isSystemInDarkTheme(); only customize when you need to override.
import io.getstream.chat.android.compose.ui.theme.ChatTheme
import io.getstream.chat.android.compose.ui.theme.StreamDesign
import androidx.compose.foundation.isSystemInDarkTheme
@Composable
fun MyChatTheme(content: @Composable () -> Unit) {
val colors = if (isSystemInDarkTheme()) {
StreamDesign.Colors.defaultDark()
} else {
StreamDesign.Colors.default()
}
val typography = StreamDesign.Typography.default()
ChatTheme(
colors = colors,
typography = typography,
// optional: componentFactory, dateFormatter, messageTextFormatter,
// channelNameFormatter, messagePreviewFormatter, ...
content = content,
)
}Never guess `StreamDesign.Colors` token names as your training data might be stale. The palette is built around two scales (brand,chrome) plus semantic tokens with the prefixesaccent*,text*,backgroundCore*,borderCore*,borderUtility*,avatarPalette*.
Commonly used `StreamDesign.Colors` tokens:
| Token | What it controls |
|---|---|
accentPrimary | Send button, primary action accent |
accentError | Destructive actions, error states |
textPrimary | Main text on default surfaces |
textSecondary | Secondary metadata text |
textTertiary | Lowest-priority text, input placeholder |
textOnAccent | Text on accent / dark backgrounds |
textLink | Hyperlinks, mentions |
backgroundCoreApp | Global application background |
backgroundCoreSurfaceDefault | Standard section background |
backgroundCoreSurfaceStrong | Stronger section background |
borderCoreDefault | Standard surface border / dividers |
borderCoreStrong | Stronger surface border |
borderCoreSubtle | Very light separators |
Read tokens at the call site via ChatTheme.colors.<token>. Full reference: see StreamDesign.kt in the SDK or getstream.io/chat/docs/sdk/android/compose/general-customization/chat-theme/.
Date formatting
import io.getstream.chat.android.ui.common.helper.DateFormatter
import java.text.SimpleDateFormat
import java.util.Date
val dateFormatter = object : DateFormatter {
private val date = SimpleDateFormat("dd/MM/yyyy")
private val time = SimpleDateFormat("HH:mm")
override fun formatDate(date: Date?) = date?.let(this.date::format).orEmpty()
override fun formatTime(date: Date?) = date?.let(this.time::format).orEmpty()
override fun formatRelativeTime(date: Date?) = /* ... */ ""
override fun formatRelativeDate(date: Date) = /* ... */ ""
}
ChatTheme(dateFormatter = dateFormatter) { /* content */ }Message text formatting
Override how message text is rendered (links, mentions, custom spans):
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.graphics.Color
import io.getstream.chat.android.compose.ui.util.MessageTextFormatter
val formatter = MessageTextFormatter { message, currentUser ->
buildAnnotatedString {
append(message.text)
addStyle(SpanStyle(color = Color.Blue), 0, minOf(3, message.text.length))
}
}
ChatTheme(messageTextFormatter = formatter) { /* content */ }MessageTextFormatter.composite(default, custom) lets you layer formatters; MessageTextFormatter.defaultFormatter(...) rebuilds the SDK default with extra spans.
Component factory customization
Bound components do not expose itemContent / emptyContent / loadingContent / trailingContent slot lambdas. Sub-piece customization (channel item rows, list headers, empty/loading states, message item parts, the input row, etc.) goes through `ChatComponentFactory`, an interface with one @Composable method per overridable piece.
Override the pieces you want, then pass your factory to ChatTheme(componentFactory = ...). Everything you don't override falls back to the SDK default.
import io.getstream.chat.android.compose.ui.theme.ChatComponentFactory
import io.getstream.chat.android.compose.ui.theme.ChatTheme
// Param types live alongside the factory under io.getstream.chat.android.compose.ui.theme.*
class CustomComponentFactory : ChatComponentFactory {
// Override only what you need; default impls cover the rest.
@Composable
override fun RowScope.ChannelItemCenterContent(params: ChannelItemCenterContentParams) {
// Custom name/last-message layout
}
}
ChatTheme(componentFactory = CustomComponentFactory()) {
ChannelsScreen(/* ... */)
}Common factory entry points (look up the Params types in ChatComponentFactory.kt):
| Method | Overrides |
|---|---|
ChannelListItemContent(...) | Whole channel row (incl. swipe wrapper) |
ChannelItemLeadingContent(...) | Channel row avatar slot |
ChannelItemCenterContent(...) | Channel row name + last message |
ChannelItemTrailingContent(...) | Channel row timestamp + unread indicator |
ChannelListHeader(...) | Header rendered by ChannelsScreen |
ChannelListEmptyContent(...) | Empty-state of the channel list |
ChannelListLoadingIndicator(...) | Initial-load placeholder |
MessageListEmptyContent(...) | Empty-state of a channel |
MessageComposer(...) / MessageComposerInput(...) | Composer surface and input row |
---
Gotchas
- Always wait for `disconnect()` completion before connecting another user. The SDK uses Room for offline persistence and runs optimistic updates; connecting a new user while disconnect is in flight risks state corruption.
- Build `ChatClient` once in `Application.onCreate()`, before any Stream Composable renders.
ChatClient.Builder(...).build()registers a singleton (a second build orphans existing socket subscriptions);ChatClient.instance()throws until that first build completes. - Never instantiate Stream ViewModels inside Composables. Use
viewModels { factory }orhiltViewModel(). Aremember { factory.create(...) }recreates state across configuration changes. - `TokenProvider.loadToken()` is synchronous. Block on your backend call inside it; the SDK runs the provider off the main thread and re-invokes it on expiry.
Chat XML — Screen Blueprints
Load only the section you are implementing. For setup, client initialization, and gotchas, see CHAT-XML.md.
Per `RULES.md` → Blueprints are mandatory, on every turn: any Stream Chat XML View, Fragment, Activity, navigation handler, deep-link route, or UI customization must be preceded by reading the matching section below — including on follow-up turns inside an existing session.
---
Request → Blueprint section
| User request signal | Section(s) to read |
|---|---|
"set up Stream", "initialize ChatClient", Application class, manifest wiring | Application Class Blueprint |
| "root host", "skip login if connected", "auto-reconnect", entry Activity | Root Host Activity Blueprint |
"login screen", "connect user", connectUser, token wiring | Login Activity Blueprint |
"channel list", ChannelListFragment, ChannelListView, channel filters/sort | Channel List Blueprint |
"channel screen", "message list", "open a channel", ChannelFragment, MessageListView + MessageComposerView | Channel Screen Blueprint |
| "navigate to channel", "open channel on tap", "tap a channel", channel click handler | Channel Tap Handling / Deep-link Blueprint + Channel Screen Blueprint |
"deep link", push notification → channel, intent extras for cid | Channel Tap Handling / Deep-link Blueprint |
"theme", colors, custom row layout, branding via TransformStyle | Custom Theming Blueprint |
| "custom channel item", channel row layout, custom view holder | Custom Channel Item Blueprint |
| "logout", switch users, tear down session | Logout Blueprint |
If the request is something not covered (Video, Feeds, Compose, or an XML surface not listed above), do not fabricate APIs — say the blueprint is not bundled and fall back per `RULES.md`.
---
Application Class Blueprint
Build ChatClient and configure global SDK state (ChatUI, TransformStyle) once in Application.onCreate(), before any Stream View inflates.
package com.example.streamchat
import android.app.Application
import android.content.pm.ApplicationInfo
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.client.logger.ChatLogLevel
class App : Application() {
override fun onCreate() {
super.onCreate()
configureChatUi()
val logLevel = if ((applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) ChatLogLevel.ALL else ChatLogLevel.NOTHING
ChatClient.Builder("your_api_key", applicationContext)
.logLevel(logLevel)
.build()
}
private fun configureChatUi() {
// Optional: ChatUI / TransformStyle configuration goes here.
// See the Custom Theming Blueprint.
}
}Register the Application in AndroidManifest.xml:
<application
android:name=".App"
android:theme="@style/Theme.MyApp"
android:label="@string/app_name">
<!-- activities ... -->
</application>Wiring:
Application.onCreate()runs before any Activity, soChatClient.instance()is safe to call from any Activity / Fragment lifecycle method.- The host application theme must descend from
Theme.MaterialComponents.*orTheme.Material3.*— Stream Views read Material attributes at inflation time. - The XML SDK artifact transitively pulls in offline + state plugins; no extra Builder calls are required for default behavior.
---
Root Host Activity Blueprint
Gate the app on connection state. Skips login if a previous session is still connected.
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import io.getstream.chat.android.client.ChatClient
class StartupActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val next = if (ChatClient.instance().getCurrentUser() != null) {
Intent(this, ChannelsActivity::class.java)
} else {
Intent(this, LoginActivity::class.java)
}
startActivity(next)
finish()
}
}Mark StartupActivity as the launcher in AndroidManifest.xml:
<activity android:name=".StartupActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>Wiring:
ChatClient.instance().getCurrentUser()is non-null while a user is connected and survives process restarts when offline persistence is enabled (default).- For a single-Activity app with Navigation Component, replace this Activity with a start destination that branches the same way before inflating any Stream View.
---
Login Activity Blueprint
Show a login Activity before connecting. Invoke connectUser once per session, not on every Activity entry.
import android.content.Intent
import android.os.Bundle
import android.widget.Button
import android.widget.EditText
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.models.User
import io.getstream.result.Result
class LoginActivity : AppCompatActivity(R.layout.activity_login) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val userIdField = findViewById<EditText>(R.id.userIdField)
val nameField = findViewById<EditText>(R.id.nameField)
val errorLabel = findViewById<TextView>(R.id.errorLabel)
val progress = findViewById<ProgressBar>(R.id.progress)
val connect = findViewById<Button>(R.id.connectButton)
connect.setOnClickListener {
val userId = userIdField.text.toString().trim()
if (userId.isEmpty()) return@setOnClickListener
connect.isEnabled = false
progress.visibility = ProgressBar.VISIBLE
errorLabel.text = ""
val user = User(
id = userId,
name = nameField.text.toString().ifBlank { userId },
)
// Demo wiring only. In production, fetch the token from your backend
// and pass a TokenProvider to connectUser instead of a static string.
ChatClient.instance()
.connectUser(user, token = "your_static_token_here")
.enqueue { result ->
progress.visibility = ProgressBar.GONE
connect.isEnabled = true
when (result) {
is Result.Success -> {
startActivity(Intent(this, ChannelsActivity::class.java))
finish()
}
is Result.Failure -> errorLabel.text = result.value.message
}
}
}
}
}activity_login.xml is a vertical LinearLayout (or ConstraintLayout) with R.id.userIdField, R.id.nameField, R.id.errorLabel, R.id.progress, R.id.connectButton.
Wiring:
connectUser(...).enqueue { ... }already hops back to the main thread for the callback — Activity state updates are safe to write directly.- For an expiring token, swap the
token = "..."argument for aTokenProvider(see CHAT-XML.md — User Authentication).
---
Channel List Blueprint
Drop-in ChannelListFragment
The Fragment supplies the header, search, and list. The host Activity implements the listener interfaces and the SDK auto-discovers them via findListener(). Do not call .setOnXxxClickListener(...) on the inner Views from the host — they are reset whenever the Fragment re-binds.
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import io.getstream.chat.android.models.Channel
import io.getstream.chat.android.models.Message
import io.getstream.chat.android.ui.feature.channels.ChannelListFragment
class ChannelsActivity :
AppCompatActivity(R.layout.activity_channels),
ChannelListFragment.ChannelListItemClickListener,
ChannelListFragment.HeaderActionButtonClickListener,
ChannelListFragment.HeaderUserAvatarClickListener,
ChannelListFragment.SearchResultClickListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(
R.id.container,
ChannelListFragment.newInstance {
showHeader(true)
showSearch(true)
headerTitle("Messages")
},
)
.commit()
}
}
override fun onChannelClick(channel: Channel) {
startActivity(ChannelActivity.createIntent(this, cid = channel.cid))
}
override fun onActionButtonClick() { /* open new-channel sheet */ }
override fun onUserAvatarClick() { /* open profile / logout */ }
override fun onSearchResultClick(message: Message) {
startActivity(ChannelActivity.createIntent(this, cid = message.cid, messageId = message.id))
}
}activity_channels.xml:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent" />Bound ChannelListView (custom shell)
Drop down to the View when you want a custom Activity layout (e.g. tabs, a master-detail pane).
import androidx.activity.viewModels
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.models.Filters
import io.getstream.chat.android.models.querysort.QuerySortByField
import io.getstream.chat.android.ui.feature.channels.list.ChannelListView
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 CustomChannelsShellActivity : AppCompatActivity(R.layout.activity_custom_channels) {
private val factory by lazy {
ChannelListViewModelFactory(
filter = Filters.and(
Filters.eq("type", "messaging"),
Filters.`in`("members", listOf(ChatClient.instance().getCurrentUser()?.id ?: "")),
),
sort = QuerySortByField.descByName("last_updated"),
limit = 30,
)
}
private val viewModel: ChannelListViewModel by viewModels { factory }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val channelListView = findViewById<ChannelListView>(R.id.channelListView)
viewModel.bindView(channelListView, this)
channelListView.setChannelItemClickListener { channel ->
startActivity(ChannelActivity.createIntent(this, cid = channel.cid))
}
}
}Wiring:
ChannelListFragment.newInstance { ... }exposes onlycustomTheme(themeResId),showHeader(...),showSearch(...),headerTitle(...). Filter / sort customization is done by overridinggetFilter()/getSort()in aChannelListFragmentsubclass —newInstancedoes not accept those.- Bound
ChannelListViewlisteners (setChannelItemClickListener,setChannelLongClickListener,setChannelDeleteClickListener,setChannelLeaveClickListener) are wired directly on the View — they are overwritten bybindViewonly for state, not for clicks. - Always call
bindViewfrom the host'sonCreate(oronViewCreated), not from a callback — the binding subscribes to the lifecycle owner you pass in.
---
Channel Screen Blueprint
Drop-in ChannelFragment
The SDK ships a self-contained Fragment that stacks ChannelHeaderView, MessageListView, and MessageComposerView and wires all three ViewModels.
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import io.getstream.chat.android.ui.feature.messages.ChannelFragment
class ChannelActivity :
AppCompatActivity(R.layout.activity_channel),
ChannelFragment.BackPressListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val cid = intent.getStringExtra(EXTRA_CID) ?: error("cid required")
val messageId = intent.getStringExtra(EXTRA_MESSAGE_ID)
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(
R.id.container,
ChannelFragment.newInstance(cid) {
showHeader(true)
messageId(messageId)
},
)
.commit()
}
}
override fun onBackPress() {
finish()
}
companion object {
const val EXTRA_CID = "cid"
const val EXTRA_MESSAGE_ID = "messageId"
fun createIntent(context: Context, cid: String, messageId: String? = null) =
Intent(context, ChannelActivity::class.java)
.putExtra(EXTRA_CID, cid)
.putExtra(EXTRA_MESSAGE_ID, messageId)
}
}activity_channel.xml:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent" />Custom screen layout (bound trio)
When the drop-in Fragment doesn't fit (custom toolbar, a side pane, extra Views around the composer), inflate the three Views yourself and wire them with the shared ChannelViewModelFactory:
activity_custom_channel.xml:
<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_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<io.getstream.chat.android.ui.feature.messages.list.MessageListView
android:id="@+id/messageListView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/channelHeaderView"
app:layout_constraintBottom_toTopOf="@id/messageComposerView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<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_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>import androidx.activity.viewModels
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 CustomChannelActivity : AppCompatActivity(R.layout.activity_custom_channel) {
private val cid: String get() = intent.getStringExtra(EXTRA_CID) ?: error("cid required")
private val factory by lazy { ChannelViewModelFactory(applicationContext, cid = cid) }
private val headerViewModel: ChannelHeaderViewModel by viewModels { factory }
private val listViewModel: MessageListViewModel by viewModels { factory }
private val composerViewModel: MessageComposerViewModel by viewModels { factory }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
headerViewModel.bindView(findViewById(R.id.channelHeaderView), this)
listViewModel.bindView(findViewById(R.id.messageListView), this)
composerViewModel.bindView(findViewById(R.id.messageComposerView), this)
findViewById<io.getstream.chat.android.ui.feature.messages.header.ChannelHeaderView>(R.id.channelHeaderView)
.setBackButtonClickListener { finish() }
}
companion object { const val EXTRA_CID = "cid" }
}Wiring:
ChannelFragment.newInstance(cid) { ... }exposescustomTheme(themeResId),showHeader(...),messageId(...),threadLoadOlderToNewer(...). Anything beyond that requires either subclassingChannelFragmentor building the trio yourself.ChannelViewModelFactory(applicationContext, cid)resolves all three ViewModels (ChannelHeaderViewModel,MessageListViewModel,MessageComposerViewModel) from a single factory instance — that's what makesviewModels { factory }reuse the same factory cleanly.- The
bindViewextensions wire cross-View defaults (composer edit/reply state, jump-to-quoted-message). Don't re-implement those handlers unless you're replacing them. MessageListView,MessageComposerView, andChannelHeaderVieweach expose their own listener setters (setBackButtonClickListener,setMessageEditHandler, etc.) — use them for navigation hooks; do not try to drive them through ViewModels.
---
Channel Tap Handling / Deep-link Blueprint
Route a tap into your channel destination
override fun onChannelClick(channel: Channel) {
startActivity(ChannelActivity.createIntent(this, cid = channel.cid))
}onChannelClick is the listener method on ChannelListFragment.ChannelListItemClickListener — implement it on the host Activity (or parent Fragment) and the SDK auto-discovers it. For the bound ChannelListView, use setChannelItemClickListener { channel -> ... }.
Intercept a tap without navigating
For analytics, an action sheet, or a side-effect-only tap, return without launching a destination:
override fun onChannelClick(channel: Channel) {
analytics.track("channel_tapped", mapOf("cid" to channel.cid))
// no navigation
}Deep-link from a push notification
Stream's NotificationHandlerFactory.createNotificationHandler(...) calls a newMessageIntent lambda when the user taps a chat push. Build an Intent that launches your channel destination directly with the cid and optional messageId:
// In your Application's ChatClient.Builder(...) chain:
val notificationConfig = NotificationConfig(
pushDeviceGenerators = listOf(/* FirebasePushDeviceGenerator(...) */),
)
val notificationHandler = NotificationHandlerFactory.createNotificationHandler(
context = applicationContext,
notificationConfig = notificationConfig,
newMessageIntent = { message, channel ->
ChannelActivity.createIntent(
context = applicationContext,
cid = channel.cid,
messageId = message.id,
)
},
)
ChatClient.Builder(apiKey, applicationContext)
.notifications(notificationConfig, notificationHandler)
.build()In ChannelActivity, read the extras into the ChannelFragment.Builder:
ChannelFragment.newInstance(cid) {
messageId(intent.getStringExtra(EXTRA_MESSAGE_ID))
}Wiring:
Channel.cidis the canonical"<type>:<id>"string — pass it as a single intent extra instead of two separate fields.messageId(...)jumps the message list to that message on first display (used by push deep-links).- The pre-built
ChannelListFragmentdoes not select a channel by id on its own — for a master-detail layout that highlights the current channel, drop down to boundChannelListView+ your own selection state.
---
Custom Theming Blueprint
The XML SDK has two paths. Use whichever is shorter for the change you need; reach for TransformStyle first.
TransformStyle (runtime, code-only)
Set transformers in Application.onCreate() before any Stream View inflates. Each *Style is a data class — use .copy(...) with named parameters. Open the matching style class in the SDK source before referencing a field; do not enumerate fields from memory.
import io.getstream.chat.android.ui.helper.StyleTransformer
import io.getstream.chat.android.ui.helper.TransformStyle
class App : Application() {
override fun onCreate() {
super.onCreate()
configureStyleTransformers()
ChatClient.Builder("your_api_key", applicationContext).build()
}
private fun configureStyleTransformers() {
TransformStyle.channelListStyleTransformer = StyleTransformer { defaultStyle ->
defaultStyle.copy(/* fields on ChannelListViewStyle */)
}
// Same pattern for messageListStyleTransformer, messageListItemStyleTransformer,
// messageComposerStyleTransformer, channelHeaderStyleTransformer, and the
// many specialized transformers (avatars, reactions, attachments, search,
// audio recorder, thread list, …) declared in TransformStyle.kt.
}
}XML theme attribute overlay
Brand the entire SDK at the theme level by inheriting from a Material theme and overriding the relevant attributes. Apply the theme on <application> (or on the host Activity).
<!-- res/values/themes.xml -->
<style name="Theme.MyApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/brand_primary</item>
<item name="colorOnSurface">@color/brand_on_surface</item>
<!-- Stream-specific attribute overrides go here. Verify any
streamUi* attribute against the SDK's attrs.xml before using it. -->
</style><!-- AndroidManifest.xml -->
<application
android:name=".App"
android:theme="@style/Theme.MyApp">
<!-- ... -->
</application>Wiring:
TransformStyle.*is set-and-leave: once configured, every subsequent inflation of the matching View applies the override. Setting it after a View is already on screen does not refresh the live View — recreate the Activity / Fragment to pick up changes.- Prefer
TransformStylefor code-driven branding; reach for XML attributes when you need theme variants (light/dark/branded) coordinated with the rest of your app's theming. - The host Activity's theme must descend from a
Theme.MaterialComponents.*orTheme.Material3.*family — Stream Views read Material attributes during inflation.
---
Custom Channel Item Blueprint
For style-only changes (text size, avatar size, bubble drawables), use TransformStyle.channelListStyleTransformer instead — see Custom Theming Blueprint. Drop down to a ChannelListItemViewHolderFactory only when you need a fundamentally different row layout.
import android.view.LayoutInflater
import android.view.ViewGroup
import io.getstream.chat.android.ui.feature.channels.list.adapter.ChannelListItem
import io.getstream.chat.android.ui.feature.channels.list.adapter.viewholder.BaseChannelListItemViewHolder
import io.getstream.chat.android.ui.feature.channels.list.adapter.viewholder.ChannelListItemViewHolderFactory
class CustomChannelListItemViewHolderFactory : ChannelListItemViewHolderFactory() {
override fun createViewHolder(
parentView: ViewGroup,
viewType: Int,
): BaseChannelListItemViewHolder {
if (viewType == TYPE_CUSTOM) {
val binding = ItemCustomChannelBinding.inflate(
LayoutInflater.from(parentView.context), parentView, false,
)
return CustomChannelViewHolder(
binding,
listenerContainer.channelClickListener,
listenerContainer.channelLongClickListener,
)
}
return super.createViewHolder(parentView, viewType)
}
override fun getItemViewType(item: ChannelListItem): Int {
if (item is ChannelListItem.ChannelItem) return TYPE_CUSTOM
return super.getItemViewType(item)
}
companion object {
private const val TYPE_CUSTOM = 1
}
}The base factory exposes listenerContainer: ChannelListListenerContainer (with channelClickListener, channelLongClickListener, deleteClickListener, moreOptionsClickListener, userClickListener, swipeListener), visibilityContainer, iconProviderContainer, and style — pass through whichever your custom view holder needs.
// Wire on the View (bound shell):
channelListView.setViewHolderFactory(CustomChannelListItemViewHolderFactory())Wiring:
ChannelListItem.ChannelItemexposeschannel: Channel— read name, last message, etc. offchannel. For unread count, use the extensionchannel.currentUserUnreadCount()fromio.getstream.chat.android.client.extensions.- Subclass
BaseChannelListItemViewHolderfor your custom view holder; overridebind(channelItem, diff)to update on data changes. - Always delegate to
super.createViewHolder(...)/super.getItemViewType(...)for items you don't customize, so built-in row types still render. - For
ChannelListFragment, overridesetupChannelList(channelListView)in a Fragment subclass and callsetViewHolderFactory(...)there.
---
Logout Blueprint
For full logout (no follow-up connectUser), disconnect(flushPersistence = true) to clear the Room cache. For switching to another user in one call, prefer ChatClient.switchUser(...) — it disconnects, deletes the push device, and connects the new user atomically.
import android.content.Intent
import io.getstream.chat.android.client.ChatClient
import io.getstream.result.Result
fun logout(onComplete: () -> Unit) {
ChatClient.instance().disconnect(flushPersistence = true).enqueue { result ->
when (result) {
is Result.Success -> onComplete()
is Result.Failure -> { /* surface error */ }
}
}
}
// Usage — switch to login after logout:
logout {
startActivity(
Intent(this, LoginActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
)
}User switching (replace logout + login with a single call):
ChatClient.instance().switchUser(nextUser, nextToken).enqueue { result ->
when (result) {
is Result.Success -> { /* connected as next user */ }
is Result.Failure -> { /* surface error */ }
}
}Wiring:
disconnect(flushPersistence = true)clears the Room cache; passfalsewhen you want to keep cached channels for the same user across temporary disconnects.disconnect(...).enqueue { ... }already hops back to the main thread for the callback — Activity / navigation calls are safe to make directly.switchUser(user, token)andswitchUser(user, tokenProvider)are both@JvmOverloads; use theTokenProviderform for expiring tokens.- If you roll your own switch via
disconnect(...).enqueue { connectUser(...) }, do not callconnectUser(...)inside the disconnect callback'sResult.Failurebranch unless you've handled the underlying error first; retrying immediately can loop on the same failing token.
Chat - XML SDK Setup & Integration
The Stream Chat XML SDK is the View-based Android SDK (artifact stream-chat-android-ui-components) — pre-built Views and Fragments for messaging UIs that drop into any AppCompat / Material project. This file covers Gradle setup, client setup, authentication, customization, and gotchas. For screen blueprints, see CHAT-XML-blueprints.md.
Rules: ../RULES.md (secrets, no dev tokens in production, proper logout).
- Blueprint — Fragment / Activity structure and View wiring
- Wiring — SDK calls for each View and ViewModel, exact extension paths
- Requirements —
minSdk21+, AppCompat / Material theme on the host Activity, Kotlin
Quick ref
- Target version: Stream Chat Android v7.x (see RULES.md — if your memory disagrees with the docs, trust the docs)
- Artifact (XML / Views):
io.getstream:stream-chat-android-ui-componentsvia Maven Central - Artifact (core only):
io.getstream:stream-chat-android-client(pulled in transitively) - First: Installation -> Manifest ->
ChatClientbuild ->connectUser->ChannelListFragmentin an Activity - Per feature: Jump to the relevant section or blueprint when wiring a screen
- Docs: If you can't find information here, check the docs:
https://getstream.io/chat/docs/sdk/android/ui/overview/
Full screen blueprints: CHAT-XML-blueprints.md — load only the section you are implementing.
---
App Integration
Installation (Gradle)
Check if the SDK is already installed in the project. If not:
With version catalog (`gradle/libs.versions.toml`):
[versions]
stream-chat-ui-components = "<latest>"
[libraries]
stream-chat-ui-components = { module = "io.getstream:stream-chat-android-ui-components", version.ref = "stream-chat-ui-components" }// app/build.gradle.kts
dependencies {
implementation(libs.stream.chat.ui.components)
}Without version catalog:
// app/build.gradle.kts
dependencies {
implementation("io.getstream:stream-chat-android-ui-components:<latest>")
}If you don't know the latest version, follow `RULES.md` → Version lookup.
The host Activity's theme must descend from a Theme.MaterialComponents.* or Theme.Material3.* family — Stream Views read Material attributes (colorPrimary, colorOnSurface, …). Adding a fresh AppCompat-only theme will cause inflate-time crashes on Stream Views.
Client Initialization
Initialize once in your Application class. Never create ChatClient inside an Activity.onCreate that re-runs, a Fragment, or a callback — the Builder registers a singleton.
import android.app.Application
import android.content.pm.ApplicationInfo
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.client.logger.ChatLogLevel
class App : Application() {
override fun onCreate() {
super.onCreate()
val logLevel = if ((applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) ChatLogLevel.ALL else ChatLogLevel.NOTHING
ChatClient.Builder("your_api_key", applicationContext)
.logLevel(logLevel)
.build()
}
}Register the Application subclass in AndroidManifest.xml:
<application
android:name=".App"
...>After build time, retrieve the client anywhere via ChatClient.instance(). The Builder registers the singleton automatically — do not store your own copy as a top-level lateinit var.
User Authentication
Default — hardcoded token (no expiry):
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.models.User
import io.getstream.result.Result
val user = User(
id = "user-id",
name = "User Name",
image = "https://example.com/avatar.png",
)
ChatClient.instance()
.connectUser(user, token = "your_static_token_here")
.enqueue { result ->
when (result) {
is Result.Success -> { /* connected */ }
is Result.Failure -> { /* handle error */ }
}
}Token provider (expiring tokens):
import io.getstream.chat.android.client.token.TokenProvider
val tokenProvider = object : TokenProvider {
override fun loadToken(): String =
yourAuthService.fetchStreamToken(userId = user.id)
}
ChatClient.instance()
.connectUser(user, tokenProvider)
.enqueue { /* ... */ }TokenProvider.loadToken() is synchronous — block on your backend call inside it; the SDK runs the provider off the main thread and re-invokes it whenever the token expires.
Disconnecting / switching users
For switching to another user, prefer ChatClient.switchUser(...) — it disconnects the current user, deletes the push device, and connects the new user atomically:
ChatClient.instance().switchUser(nextUser, nextToken).enqueue { result ->
when (result) {
is Result.Success -> { /* connected as next user */ }
is Result.Failure -> { /* handle error */ }
}
}For a full logout (no follow-up connectUser), call disconnect(flushPersistence = true) to clear the offline cache. If you do roll your own switch via disconnect(...).enqueue { connectUser(...) }, always wait for disconnect() to complete before connecting — connecting in flight risks state corruption.
Showing the Channel List
The drop-in ChannelListFragment renders the channel list, header, and search. Host it inside an AppCompatActivity and implement listener interfaces on the host — the Fragment auto-discovers them via findListener():
import androidx.appcompat.app.AppCompatActivity
import io.getstream.chat.android.models.Channel
import io.getstream.chat.android.ui.feature.channels.ChannelListFragment
class ChannelsActivity :
AppCompatActivity(R.layout.activity_channels),
ChannelListFragment.ChannelListItemClickListener,
ChannelListFragment.HeaderActionButtonClickListener,
ChannelListFragment.HeaderUserAvatarClickListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(R.id.container, ChannelListFragment.newInstance {
showHeader(true)
showSearch(true)
})
.commit()
}
}
override fun onChannelClick(channel: Channel) { /* navigate to ChannelActivity */ }
override fun onActionButtonClick() { /* new-channel sheet */ }
override fun onUserAvatarClick() { /* open profile / logout */ }
}The host Activity's R.layout.activity_channels is a single FrameLayout with android:id="@+id/container". The Fragment supplies its own header, search, and list inflated from StreamUiFragmentChannelListBinding.
---
ChatUI Global Config
ChatUI is the XML SDK's process-wide configuration object — the View-based equivalent of Compose's ChatTheme(componentFactory = ...). Set fields once before any Stream View inflates (typically in Application.onCreate() or the host Activity before setContentView).
import io.getstream.chat.android.ui.ChatUI
ChatUI.fonts = MyChatFonts()
ChatUI.dateFormatter = MyDateFormatter()
ChatUI.channelNameFormatter = ChannelNameFormatter { channel, currentUser ->
channel.name.ifEmpty { channel.cid }
}
ChatUI.messagePreviewFormatter = MessagePreviewFormatter { channel, message, currentUser ->
message.text
}
ChatUI.userAvatarRenderer = MyUserAvatarRenderer()
ChatUI.channelAvatarRenderer = MyChannelAvatarRenderer()Never guess `ChatUI` field names from training data. OpenChatUI.ktin the SDK source before referencing a field — the public surface includes formatters (date, channel name, message preview), avatar renderers, attachment factory managers, fonts, and feature flags (draftMessagesEnabled,autoTranslationEnabled,videoThumbnailsEnabled, …).
ChatUI is set-and-leave: changing fields after a Stream View is already inflated does not refresh the live View — kill and recreate the Fragment / Activity to pick up changes.
---
Themes and Styles
The XML SDK has two complementary styling paths. Use both.
1. Runtime style transformers (TransformStyle)
Override individual style fields in code, applied to every inflation of the matching View. Set transformers in Application.onCreate() before any Stream View inflates.
import io.getstream.chat.android.ui.helper.TransformStyle
import io.getstream.chat.android.ui.helper.StyleTransformer
TransformStyle.channelListStyleTransformer = StyleTransformer { defaultStyle ->
defaultStyle.copy(/* fields on ChannelListViewStyle */)
}Common transformers: channelListStyleTransformer, messageListStyleTransformer, messageListItemStyleTransformer, messageComposerStyleTransformer, channelHeaderStyleTransformer. The full set in TransformStyle.kt covers ~25+ surfaces — avatars, reactions, scroll button, attachments (file / media / giphy / poll / quoted), search input, mention list, typing indicator, audio recorder, thread list, etc. Each *Style is a data class — use .copy(...) with named parameters. Open TransformStyle.kt and the matching style class in the SDK source before referencing a transformer or field; do not enumerate from memory.
2. XML theme attributes
Stream Views read attributes off the host Activity's theme via the streamUiTheme attribute and the streamUi* namespaced attributes. To brand the entire SDK at the theme level, define a child theme in res/values/themes.xml:
<style name="Theme.MyApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/brand_primary</item>
<item name="colorOnSurface">@color/brand_on_surface</item>
<!-- Stream-specific overrides go here, e.g. -->
<!-- <item name="streamUiChannelListAvatarSize">48dp</item> -->
</style>Apply the theme in AndroidManifest.xml on the application or activity. Use individual streamUi* attributes only when you've verified them against the SDK's attrs.xml — do not invent attribute names.
For most theming, prefer `TransformStyle` over per-attribute XML; it's terser, type-safe, and easier to maintain.
---
ViewModel Patterns
Every Stream View has a matching ViewModel and a bindView(view, lifecycleOwner) extension that wires state and listeners in one call. Always create ViewModels via a factory passed to `viewModels { factory }` (Activity / Fragment), or with Hilt via `by viewModels()` on a `@AndroidEntryPoint` host plus `@HiltViewModel` on the ViewModel — never inside a transient scope. hiltViewModel() is a Compose-only API; do not use it from an Activity / Fragment.
Channel list
import androidx.activity.viewModels
import io.getstream.chat.android.ui.feature.channels.list.ChannelListView
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
import io.getstream.chat.android.models.Filters
class CustomChannelListActivity : AppCompatActivity(R.layout.activity_custom_channels) {
private val factory by lazy {
ChannelListViewModelFactory(
filter = Filters.and(
Filters.eq("type", "messaging"),
Filters.`in`("members", listOf(ChatClient.instance().getCurrentUser()?.id ?: "")),
),
)
}
private val viewModel: ChannelListViewModel by viewModels { factory }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val channelListView = findViewById<ChannelListView>(R.id.channelListView)
viewModel.bindView(channelListView, this)
channelListView.setChannelItemClickListener { channel -> /* navigate */ }
}
}ChannelListViewModelFactory parameters: filter, sort (default ChannelListViewModel.DEFAULT_SORT), limit, messageLimit, memberLimit, isDraftMessagesEnabled, chatEventHandlerFactory. filter = null falls back to Filters.and(Filters.eq("type", "messaging"), Filters.in("members", listOf(currentUserId))).
Channel screen (header + list + composer)
The three Views share a single factory (ChannelViewModelFactory) — one factory creates all three ViewModels.
import androidx.activity.viewModels
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 CustomChannelActivity : AppCompatActivity(R.layout.activity_custom_channel) {
private val cid: String get() = intent.getStringExtra(EXTRA_CID) ?: "messaging:general"
private val factory by lazy { ChannelViewModelFactory(applicationContext, cid = cid) }
private val headerViewModel: ChannelHeaderViewModel by viewModels { factory }
private val listViewModel: MessageListViewModel by viewModels { factory }
private val composerViewModel: MessageComposerViewModel by viewModels { factory }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
headerViewModel.bindView(findViewById(R.id.channelHeaderView), this)
listViewModel.bindView(findViewById(R.id.messageListView), this)
composerViewModel.bindView(findViewById(R.id.messageComposerView), this)
}
companion object {
const val EXTRA_CID = "cid"
}
}ChannelViewModelFactory parameters: context, cid, messageId (jump-to-message deep link), parentMessageId (open a thread), threadLoadOlderToNewer.
The bindView extensions also wire default behavior between the three Views (e.g. tapping a quoted message scrolls the list, the composer's edit/reply state syncs from the list). Don't reproduce those handlers manually unless you're replacing them.
---
Logging
Disabled by default. Enable on the Builder, ideally only in debug builds:
ChatClient.Builder(apiKey, context)
.logLevel(if ((context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) ChatLogLevel.ALL else ChatLogLevel.NOTHING)
.build()Levels: ChatLogLevel.ALL, DEBUG, WARN, ERROR, NOTHING.
For finer control, pass a ChatLoggerHandler via .loggerHandler(...) and bridge to your existing logger (Timber, etc.).
---
Gotchas
- The host Activity must use a Material(Components) theme. Stream Views read
colorPrimary,colorOnSurface, and other Material attributes during inflation; a plainTheme.AppCompat.*will crash with aMaterialAttributeslookup failure. - Always wait for `disconnect()` completion before connecting another user. The SDK uses Room for offline persistence and runs optimistic updates; connecting a new user while disconnect is in flight risks state corruption.
- Build `ChatClient` once in `Application.onCreate()`, before any Stream View inflates.
ChatClient.Builder(...).build()registers a singleton (a second build orphans existing socket subscriptions);ChatClient.instance()throws until that first build completes. - `ChatUI` and `TransformStyle` must be configured before the first Stream View inflates — typically in
Application.onCreate(). Setting them later does not retroactively refresh visible Views. - Listener interfaces on `ChannelListFragment` are auto-discovered via
findListener()— implement them on the host Activity (or parent Fragment) instead of calling.setOnXxxClickListener(...)manually. Manual setters on the inner Views are overwritten when the Fragment re-binds. - Never instantiate Stream ViewModels in arbitrary scopes. Use
viewModels { factory }orhiltViewModel(). Creating them in a callback or locallazyoutside the Activity scope leaks listeners and breaks state restoration. - `TokenProvider.loadToken()` is synchronous. Block on your backend call inside it; the SDK runs the provider off the main thread and re-invokes it on expiry.
Stream Android - non-negotiable rules
Every rule below is stated once. Other files reference this file - do not duplicate these rules inline.
---
Target SDK version
Target Stream Chat Android SDK major is v7+ (io.getstream:stream-chat-android-compose:7.x and matching stream-chat-android-client / stream-chat-android-ui-components). v7 changed plugin wiring, theme APIs, and several Composable signatures vs. v6. If a pattern in your training data conflicts with this skill's blueprints or the v7 docs, trust the docs - do not fall back to remembered v6 shapes.
For Stream Video, target the latest published io.getstream:stream-video-android-ui-compose (and its transitive -core). Verify any signature you are about to write against the bundled VIDEO-COMPOSE.md reference or the Video Android docs.
For Stream Feeds (V3), target the latest published io.getstream:stream-feeds-android-client. Feeds V3 is not yet v1 — public APIs may shift between versions, and there is no pre-built UI artifact. Verify any signature against the bundled FEEDS-COMPOSE.md reference, the Feeds Android docs, or the source on `GetStream/stream-feeds-android`.
Version lookup
When you need the current Stream artifact version, use one of these sources:
- Maven Central (Sonatype):
https://central.sonatype.com/artifact/io.getstream/stream-chat-android-compose/versions(or/stream-chat-android-ui-components/versions,/stream-video-android-ui-compose/versions,/stream-feeds-android-client/versions) - GitHub releases:
https://github.com/GetStream/stream-chat-android/releases,https://github.com/GetStream/stream-video-android/releases,https://github.com/GetStream/stream-feeds-android/releases
Do not use `search.maven.org` — it is deprecated and its index is stale; it will lead you to ship outdated versions by mistake. If a tool result shows search.maven.org as the source, discard it and re-query one of the sources above.
---
Secrets and auth
Never hardcode a Stream API secret in app code, AndroidManifest.xml, BuildConfig, local.properties shipped to source control, or chat. The client may hold the API key and a user token; the API secret stays server-side only.
Default token model:
- Use a backend-issued token (via a
TokenProvider) when the user already has a backend. - Use a CLI-generated token (
getstream token <user_id>, optionally with--ttl 30s|2h|1d- see `credentials.md`) for local dev and demo flows - this is the preferred path when no backend exists. The binary isgetstream, notstream-cli. - Use a static token only when the user explicitly wants to paste one themselves.
- Never use
ChatClient.devToken(userId)in production - dev tokens disable token verification and let any client impersonate any user. - Never invent or generate fake production credentials.
- The API secret never leaves the CLI/server side; only the API key and the generated token go into app code.
---
Project ownership
Preserve the app's existing architecture:
- Do not convert XML/Views to Compose unless the user asks.
- Do not convert Compose to XML/Views unless the user asks.
- Do not flip a project off Gradle Kotlin DSL onto Groovy (or vice versa) unless the user asks.
- Do not ignore the version catalog (
gradle/libs.versions.toml) when the project already uses one - add Stream entries there instead of hardcoding versions inbuild.gradle.kts. - Do not flatten an existing Hilt/Koin DI graph, navigation setup, or multi-module structure just to fit a sample.
If there is no Android project, do not try to scaffold one from the CLI. Tell the user to create the app in Android Studio first, then continue.
---
Client lifetime
Initialize Stream SDK clients once at app launch via the Application class (or an equivalent app-scoped owner like a Hilt/Koin singleton). Never create a client in:
- a
@Composablefunction body - a
remember { ... }factory that re-runs on recomposition - an
Activity.onCreatethat runs every time the activity is recreated - a transient callback or coroutine with no stored owner
Stateful SDK objects (the Stream-provided ViewModels, query controllers, and the Call returned by streamVideo.call(type, id)) must live in owned scopes (viewModels { factory }, hiltViewModel(), an Activity or ViewModel field), not in a Composable body or remember { ... }.
If the user switches accounts, tear down the current session before starting the next one — see the matching reference file for the exact disconnect / logout calls.
---
UI and concurrency
UI state changes belong on the main dispatcher. Prefer explicit ownership over implicit globals:
- collect SDK
Flows withcollectAsStateWithLifecycle()(Compose) orrepeatOnLifecycle(Views) - run
client.connectUser(...).enqueue { ... }(orawait()) from a lifecycle-aware scope - avoid creating ad-hoc
CoroutineScopes inside Composables - userememberCoroutineScopeor a ViewModel scope
When adapting examples, match the project's actual entry points (Application, Activity, Fragment, navigation graph) instead of forcing a different one.
---
Reference discipline
Load only the product/UI-layer reference files that match the request.
CHAT-COMPOSE.mdfor Chat + Jetpack ComposeCHAT-COMPOSE-blueprints.mdfor concrete Composable screen structureCHAT-XML.mdfor Chat + XML (View-based SDK)CHAT-XML-blueprints.mdfor concrete Activity/Fragment + View structureVIDEO-COMPOSE.mdfor Video + Jetpack ComposeVIDEO-COMPOSE-blueprints.mdfor concrete call-screen structureFEEDS-COMPOSE.mdfor Feeds + Jetpack Compose (headless data SDK — no pre-built UI)FEEDS-COMPOSE-blueprints.mdfor custom Composable scaffolding driven byFeedState/ActivityState
Do not invent missing API details for product/UI-layer combinations not listed above. If a requested reference is not bundled yet, say so plainly and fall back to shared guidance from `sdk.md` or live docs only when the user wants that.
Blueprints are mandatory, on every turn
Before writing or editing any Stream Chat, Stream Video, or Stream Feeds screen, Composable, View, Fragment, Activity, navigation handler, deep-link route, theming override, ringing handler, or channel/message/call/feed UI customization, you must open the matching section of the corresponding <PRODUCT>-<UI_LAYER>-blueprints.md file (e.g. `references/CHAT-COMPOSE-blueprints.md`, `references/CHAT-XML-blueprints.md`, `references/VIDEO-COMPOSE-blueprints.md`, `references/FEEDS-COMPOSE-blueprints.md`) and follow its structure. This applies on every turn, not just the first time the skill is invoked in a session — follow-up requests like "add navigation to the channel screen", "open a channel on tap", "add a button to start a call", "customize the call controls", "theme the call screen", "add a comments sheet", or "add a follow button" count as new screen work and require a fresh blueprint read.
Use the Request → Blueprint section table at the top of each blueprints file to resolve which section to read. If no section matches, say so explicitly before improvising — do not silently fall back to remembered SDK shapes from training data.
Do not assume that because a blueprint section was read earlier in the session, its content is still in working context. Re-read the relevant section before each Stream screen edit.
Before changing the public surface of an existing Stream screen — its Composable signature, nav arguments, exposed callbacks, or ViewModel's public API — grep the project for usages first. Blueprint conformance alone does not catch breakage in callers outside the files you have already read this session.
Related skills
FAQ
Which Stream SDKs does stream-android cover?
stream-android covers GetStream’s Android SDKs for chat, activity feeds, and video, guiding client setup, channel configuration, and UI integration patterns for each.
What platform does stream-android target?
stream-android targets Android applications built with Kotlin or Java, focusing on correct Stream SDK client initialization, channel wiring, and UI component integration.