Creating a Project
Already all-in on Jetpack Compose? Check out our Compose tutorial instead of this one!
The completed app for each step of the tutorial is available on GitHub.
To get started with the Android Chat SDK, open Android Studio and create a new project.
- Select the
Empty Views Activity
template - Name the project
ChatTutorial
- Set the package name to
com.example.chattutorial
- Select your language - Kotlin (recommended) or Java
- Set the Minimum SDK to 21 (or higher)
- JVM targets 11 (or higher)
If you're using an up-to-date version of Android Studio, your newly created project should already be using a Theme.MaterialComponents
theme as the parent to its app theme (you can check this in styles.xml
or themes.xml
). If you're running an older version, change the parent theme to be a Material theme instead of Theme.AppCompat
.
If you want to keep using AppCompat theming, you can use a Bridge Theme instead.
Our SDKs are available from MavenCentral, with some of our dependencies being hosted on Jitpack. Update your repositories in the settings.gradle
file like so:
12345678dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() maven { url "https://jitpack.io" } } }
First, we'll enable View Binding. Next, we're going to add the Stream Chat SDK and Coil to our project dependencies. Open up the app module's build.gradle
script and make the following changes:
1234567891011121314151617181920212223242526272829303132333435363738394041plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' } android { compileSdk 34 namespace "com.example.chattutorial" defaultConfig { applicationId "com.example.chattutorial" minSdk 21 targetSdk 34 versionCode 1 versionName "1.0" } // Enable ViewBinding buildFeatures { viewBinding true } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { jvmTarget = "11" } } dependencies { // Add new dependencies implementation "io.getstream:stream-chat-android-ui-components:6.0.2" implementation "io.getstream:stream-chat-android-offline:6.0.2" implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2" implementation "com.google.android.material:material:1.9.0" implementation "androidx.activity:activity-ktx:1.7.2" implementation "io.coil-kt:coil:2.4.0" }
12345678910111213141516171819202122232425262728293031323334353637383940plugins { id 'com.android.application' } android { compileSdk 34 namespace "com.example.chattutorial" defaultConfig { applicationId "com.example.chattutorial" minSdk 21 targetSdk 34 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } // Enable ViewBinding buildFeatures { viewBinding true } } dependencies { // Add new dependencies implementation "io.getstream:stream-chat-android-ui-components:6.0.2" implementation "io.getstream:stream-chat-android-offline:6.0.2" implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2" implementation "com.google.android.material:material:1.9.0" implementation "androidx.activity:activity-ktx:1.7.2" implementation "io.coil-kt:coil:2.4.0" }
After you edit your Gradle files, make sure to sync the project (Android Studio will prompt you for this) with the new changes.
Displaying a List of Channels
Stream provides a low-level client, an offline support library, and convenient UI components to help you quickly build your messaging interface. In this section, we'll be using the UI components to quickly display a channel list.
First, open up activity_main.xml
, and change the contents of the file to the following to display a full screen ChannelListView
:
1234567891011121314151617<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <io.getstream.chat.android.ui.feature.channels.list.ChannelListView android:id="@+id/channelListView" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
Next, open up MainActivity
and replace the file's contents with the following code:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879package com.example.chattutorial import android.os.Bundle import android.widget.Toast import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import com.example.chattutorial.databinding.ActivityMainBinding import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.logger.ChatLogLevel import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.User import io.getstream.chat.android.offline.plugin.factory.StreamOfflinePluginFactory import io.getstream.chat.android.state.plugin.config.StatePluginConfig import io.getstream.chat.android.state.plugin.factory.StreamStatePluginFactory import io.getstream.chat.android.ui.viewmodel.channels.ChannelListViewModel import io.getstream.chat.android.ui.viewmodel.channels.ChannelListViewModelFactory import io.getstream.chat.android.ui.viewmodel.channels.bindView class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Step 0 - inflate binding binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) // Step 1 - Set up the OfflinePlugin for offline storage val offlinePluginFactory = StreamOfflinePluginFactory(appContext = this) val statePluginFactory = StreamStatePluginFactory( config = StatePluginConfig( backgroundSyncEnabled = true, userPresence = true, ), appContext = this, ) // Step 2 - Set up the client for API calls with the plugin for offline storage val client = ChatClient.Builder("uun7ywwamhs9", applicationContext) .withPlugins(offlinePluginFactory, statePluginFactory) .logLevel(ChatLogLevel.ALL) // Set to NOTHING in prod .build() // Step 3 - Authenticate and connect the user val user = User( id = "tutorial-droid", name = "Tutorial Droid", image = "https://bit.ly/2TIt8NR" ) client.connectUser( user = user, token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidHV0b3JpYWwtZHJvaWQifQ.WwfBzU1GZr0brt_fXnqKdKhz3oj0rbDUm2DqJO_SS5U" ).enqueue { if (it.isSuccess) { // Step 4 - Set the channel list filter and order // This can be read as requiring only channels whose "type" is "messaging" AND // whose "members" include our "user.id" val filter = Filters.and( Filters.eq("type", "messaging"), Filters.`in`("members", listOf(user.id)) ) val viewModelFactory = ChannelListViewModelFactory(filter, ChannelListViewModel.DEFAULT_SORT) val viewModel: ChannelListViewModel by viewModels { viewModelFactory } // Step 5 - Connect the ChannelListViewModel to the ChannelListView, loose // coupling makes it easy to customize viewModel.bindView(binding.channelListView, this) binding.channelListView.setChannelItemClickListener { channel -> startActivity(ChannelActivity4.newIntent(this, channel)) } } else { Toast.makeText(this, "something went wrong!", Toast.LENGTH_SHORT).show() } } } }
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384package com.example.chattutorial; import static java.util.Collections.singletonList; import android.os.Bundle; import androidx.appcompat.app.AppCompatActivity; import androidx.lifecycle.ViewModelProvider; import com.example.chattutorial.databinding.ActivityMainBinding; import org.jetbrains.annotations.Nullable; import io.getstream.chat.android.client.ChatClient; import io.getstream.chat.android.client.logger.ChatLogLevel; import io.getstream.chat.android.models.FilterObject; import io.getstream.chat.android.models.Filters; import io.getstream.chat.android.models.User; import io.getstream.chat.android.offline.plugin.factory.StreamOfflinePluginFactory; import io.getstream.chat.android.state.plugin.config.StatePluginConfig; import io.getstream.chat.android.state.plugin.factory.StreamStatePluginFactory; import io.getstream.chat.android.ui.viewmodel.channels.ChannelListViewModel; import io.getstream.chat.android.ui.viewmodel.channels.ChannelListViewModelBinding; import io.getstream.chat.android.ui.viewmodel.channels.ChannelListViewModelFactory; public final class MainActivity extends AppCompatActivity { protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); // Step 0 - inflate binding ActivityMainBinding binding = ActivityMainBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); // Step 1 - Set up the OfflinePlugin for offline storage StreamOfflinePluginFactory streamOfflinePluginFactory = new StreamOfflinePluginFactory( getApplicationContext() ); StreamStatePluginFactory streamStatePluginFactory = new StreamStatePluginFactory( new StatePluginConfig(true, true), this ); // Step 2 - Set up the client for API calls with the plugin for offline storage ChatClient client = new ChatClient.Builder("uun7ywwamhs9", getApplicationContext()) .withPlugins(streamOfflinePluginFactory, streamStatePluginFactory) .logLevel(ChatLogLevel.ALL) // Set to NOTHING in prod .build(); // Step 3 - Authenticate and connect the user User user = new User.Builder() .withId("tutorial-droid") .withName("Tutorial Droid") .withImage("https://bit.ly/2TIt8NR") .build(); client.connectUser( user, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidHV0b3JpYWwtZHJvaWQifQ.WwfBzU1GZr0brt_fXnqKdKhz3oj0rbDUm2DqJO_SS5U" ).enqueue(result -> { // Step 4 - Set the channel list filter and order // This can be read as requiring only channels whose "type" is "messaging" AND // whose "members" include our "user.id" FilterObject filter = Filters.and( Filters.eq("type", "messaging"), Filters.in("members", singletonList(user.getId())) ); ViewModelProvider.Factory factory = new ChannelListViewModelFactory.Builder() .filter(filter) .sort(ChannelListViewModel.DEFAULT_SORT) .build(); ChannelListViewModel channelsViewModel = new ViewModelProvider(this, factory).get(ChannelListViewModel.class); // Step 5 - Connect the ChannelListViewModel to the ChannelListView, loose // coupling makes it easy to customize ChannelListViewModelBinding.bind(channelsViewModel, binding.channelListView, this); binding.channelListView.setChannelItemClickListener( channel -> startActivity(ChannelActivity4.newIntent(this, channel)) ); }); } }
Let's have a quick look at the source code shown above:
- Step 1: We create a
StreamOfflinePluginFactory
to provide offline support. TheOfflinePlugin
class employs a new caching mechanism powered by side-effects we applied toChatClient
functions. - Step 2: We create a connection to Stream by initializing the
ChatClient
using an API key. This key points to a tutorial environment, but you can sign up for a free Chat trial to get your own later. Next, we add theofflinePluginFactory
to theChatClient
withwithPlugin
method for providing offline storage capabilities. For a production app, we recommend initializing thisChatClient
in your Application class. - Step 3: We create a
User
instance and pass it to theChatClient
'sconnectUser
method, along with a pre-generated user token, in order to authenticate the user. In a real-world application, your authentication backend would generate such a token at login / signup and hand it over to the mobile app. For more information, see the Tokens & Authentication page. - Step 4: We configure the
ChannelListViewModelFactory
with a filter and a sort option. We’re using the default sort option which orders the channels bylast_updated_at
time, putting the most recently used channels on the top. For the filter, we’re specifying all channels of typemessaging
where the current user is a member. The documentation about Querying Channels covers this in more detail. - Step 5: We bind our ChannelListView to the ChannelListViewModel by calling the
bindView
function.
Build and run your application - you should see the channel list interface shown on the right.
Creating a Chat Experience
Next, let's open up one of these channels and start chatting. To do this, we'll leverage the MessageListHeaderView, MessageListView, and MessageComposerView components.
Although our default components provide a robust experience, it's possible to configure and customize them, or even use your own custom views.
Create a new Empty Views Activity (New -> Activity -> Empty Views Activity) and name it ChannelActivity
.
Make sure that ChannelActivity
is added to your manifest. Android Studio does this automatically if you use the wizard to create the Activity, but you'll need to add it yourself if you manually created the Activity class.
Open up activity_channel.xml
and change the layout to the following:
123456789101112131415161718192021222324252627282930313233<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <io.getstream.chat.android.ui.feature.messages.header.MessageListHeaderView android:id="@+id/messageListHeaderView" android:layout_width="0dp" android:layout_height="wrap_content" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <io.getstream.chat.android.ui.feature.messages.list.MessageListView android:id="@+id/messageListView" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toTopOf="@+id/messageComposerView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/messageListHeaderView" /> <io.getstream.chat.android.ui.feature.messages.composer.MessageComposerView android:id="@+id/messageComposerView" android:layout_width="0dp" android:layout_height="wrap_content" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
Next, replace the code in ChannelActivity
with this code:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091package com.example.chattutorial import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.addCallback import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import com.example.chattutorial.databinding.ActivityChannelBinding import io.getstream.chat.android.models.Channel import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.MessageMode import io.getstream.chat.android.ui.viewmodel.messages.MessageComposerViewModel import io.getstream.chat.android.ui.viewmodel.messages.MessageListHeaderViewModel import io.getstream.chat.android.ui.viewmodel.messages.MessageListViewModel import io.getstream.chat.android.ui.viewmodel.messages.MessageListViewModelFactory import io.getstream.chat.android.ui.viewmodel.messages.bindView class ChannelActivity : AppCompatActivity() { private lateinit var binding: ActivityChannelBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Step 0 - inflate binding binding = ActivityChannelBinding.inflate(layoutInflater) setContentView(binding.root) val cid = checkNotNull(intent.getStringExtra(CID_KEY)) { "Specifying a channel id is required when starting ChannelActivity" } // Step 1 - Create three separate ViewModels for the views so it's easy // to customize them individually val factory = MessageListViewModelFactory(this, cid) val messageListHeaderViewModel: MessageListHeaderViewModel by viewModels { factory } val messageListViewModel: MessageListViewModel by viewModels { factory } val messageComposerViewModel: MessageComposerViewModel by viewModels { factory } // TODO set custom Imgur attachment factory // Step 2 - Bind the view and ViewModels, they are loosely coupled so it's easy to customize messageListHeaderViewModel.bindView(binding.messageListHeaderView, this) messageListViewModel.bindView(binding.messageListView, this) messageComposerViewModel.bindView(binding.messageComposerView, this) // Step 3 - Let both MessageListHeaderView and MessageComposerView know when we open a thread messageListViewModel.mode.observe(this) { mode -> when (mode) { is MessageMode.MessageThread -> { messageListHeaderViewModel.setActiveThread(mode.parentMessage) messageComposerViewModel.setMessageMode(MessageMode.MessageThread(mode.parentMessage)) } is MessageMode.Normal -> { messageListHeaderViewModel.resetThread() messageComposerViewModel.leaveThread() } } } // Step 4 - Let the message input know when we are editing a message binding.messageListView.setMessageEditHandler { message -> messageComposerViewModel.performMessageAction(Edit(message)) } // Step 5 - Handle navigate up state messageListViewModel.state.observe(this) { state -> if (state is MessageListViewModel.State.NavigateUp) { finish() } } // Step 6 - Handle back button behaviour correctly when you're in a thread val backHandler = { messageListViewModel.onEvent(MessageListViewModel.Event.BackButtonPressed) } binding.messageListHeaderView.setBackButtonClickListener(backHandler) onBackPressedDispatcher.addCallback(this) { backHandler() } } companion object { private const val CID_KEY = "key:cid" fun newIntent(context: Context, channel: Channel): Intent = Intent(context, ChannelActivity::class.java).putExtra(CID_KEY, channel.cid) } }
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101package com.example.chattutorial; import android.content.Context; import android.content.Intent; import android.os.Bundle; import androidx.activity.OnBackPressedCallback; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.lifecycle.ViewModelProvider; import com.example.chattutorial.databinding.ActivityChannelBinding; import io.getstream.chat.android.models.Channel; import io.getstream.chat.android.models.Message; import io.getstream.chat.android.ui.common.state.messages.Edit; import io.getstream.chat.android.ui.common.state.messages.MessageMode; import io.getstream.chat.android.ui.feature.messages.header.MessageListHeaderView; import io.getstream.chat.android.ui.viewmodel.messages.MessageComposerViewModel; import io.getstream.chat.android.ui.viewmodel.messages.MessageComposerViewModelBinding; import io.getstream.chat.android.ui.viewmodel.messages.MessageListHeaderViewModel; import io.getstream.chat.android.ui.viewmodel.messages.MessageListHeaderViewModelBinding; import io.getstream.chat.android.ui.viewmodel.messages.MessageListViewModel; import io.getstream.chat.android.ui.viewmodel.messages.MessageListViewModelBinding; import io.getstream.chat.android.ui.viewmodel.messages.MessageListViewModelFactory; public class ChannelActivity extends AppCompatActivity { private final static String CID_KEY = "key:cid"; public static Intent newIntent(Context context, Channel channel) { final Intent intent = new Intent(context, ChannelActivity.class); intent.putExtra(CID_KEY, channel.getCid()); return intent; } @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); // Step 0 - inflate binding ActivityChannelBinding binding = ActivityChannelBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); String cid = getIntent().getStringExtra(CID_KEY); if (cid == null) { throw new IllegalStateException("Specifying a channel id is required when starting ChannelActivity"); } // Step 1 - Create three separate ViewModels for the views so it's easy // to customize them individually ViewModelProvider.Factory factory = new MessageListViewModelFactory.Builder(this) .cid(cid) .build(); ViewModelProvider provider = new ViewModelProvider(this, factory); MessageListHeaderViewModel messageListHeaderViewModel = provider.get(MessageListHeaderViewModel.class); MessageListViewModel messageListViewModel = provider.get(MessageListViewModel.class); MessageComposerViewModel messageComposerViewModel = provider.get(MessageComposerViewModel.class); // TODO set custom Imgur attachment factory // Step 2 - Bind the view and ViewModels, they are loosely coupled so it's easy to customize MessageListHeaderViewModelBinding.bind(messageListHeaderViewModel, binding.messageListHeaderView, this); MessageListViewModelBinding.bind(messageListViewModel, binding.messageListView, this); MessageComposerViewModelBinding.bind(messageComposerViewModel, binding.messageComposerView, this); // Step 3 - Let both MessageListHeaderView and MessageComposerView know when we open a thread messageListViewModel.getMode().observe(this, mode -> { if (mode instanceof MessageMode.MessageThread) { Message parentMessage = ((MessageMode.MessageThread) mode).getParentMessage(); messageListHeaderViewModel.setActiveThread(parentMessage); messageComposerViewModel.setMessageMode(new MessageMode.MessageThread(parentMessage)); } else if (mode instanceof MessageMode.Normal) { messageListHeaderViewModel.resetThread(); messageComposerViewModel.leaveThread(); } }); // Step 4 - Let the message input know when we are editing a message binding.messageListView.setMessageEditHandler(message -> { messageComposerViewModel.performMessageAction(new Edit(message)); }); // Step 5 - Handle navigate up state messageListViewModel.getState().observe(this, state -> { if (state instanceof MessageListViewModel.State.NavigateUp) { finish(); } }); // Step 6 - Handle back button behaviour correctly when you're in a thread MessageListHeaderView.OnClickListener backHandler = () -> messageListViewModel.onEvent(MessageListViewModel.Event.BackButtonPressed.INSTANCE); binding.messageListHeaderView.setBackButtonClickListener(backHandler); getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { backHandler.onClick(); } }); } }
Configuring ChannelActivity
involves a few steps, so let's review what's going on.
- Step 1: We set up three ViewModels:
- MessageListHeaderViewModel - Provides useful information about the channel.
- MessageListViewModel - Loads a channel's messages, while also providing useful information about the current state of the channel.
- MessageComposerViewModel - Responsible for composing and sending new messages.
- Step 2: We bind these
ViewModels
to their respective Views. This loose coupling between components makes it easy to customize things, or only use the components you find necessary. - Steps 3 and 4: We coordinate the
MessageListView
with bothMessageListHeaderView
andMessageComposerView
. TheMessageComposerView
needs to know when you’re editing a message or when you enter a message thread, which is also a piece of useful information forMessageListHeaderView
. - Steps 5 and 6: We create a back button handler, and set the same behavior for the
MessageListHeaderView
and the Activity'sOnBackPressedDispatcher
. The handler sends aBackButtonPressed
event to theMessageListViewModel
, which will decide how to handle this event. If we're in a message thread, it'll navigate back to the channel. If we're already in the channel, it will navigate to the channel list by emitting aNavigateUp
state that we handle by finishingChannelActivity
.
Lastly, we want to launch ChannelActivity
when you tap a channel in the channel list. Open MainActivity
and replace the TODO at the end of the onCreate
method:
123binding.channelListView.setChannelItemClickListener { channel -> startActivity(ChannelActivity.newIntent(this, channel)) }
123binding.channelListView.setChannelItemClickListener( channel -> startActivity(ChannelActivity.newIntent(this, channel)) );
If you run the application and tap on a channel, you'll now see the chat interface shown on the right.
Chat Features
Congrats on getting your chat experience up and running! Stream Chat provides you with all the features you need to build an engaging messaging experience:
- Offline support: send messages, edit messages and send reactions while offline
- Link previews: generated automatically when you send a link
- Commands: type
/
to use commands like/giphy
- Reactions: long-press on a messages to add a reaction
- Attachments: use the paperclip button in
MessageComposerView
to attach images and files - Edit message: long-press on your message for message options, including editing
- Threads: start message threads to reply to any message
You should also notice that, regardless of whether you chose to develop your app in Kotlin or Java, the chat loads very quickly. Stream’s API is powered by Go, RocksDB and Raft. The API tends to respond in less than 10ms and powers activity feeds and chat for over a billion end users.
Some of the features are hard to see in action with just one user online. You can open the same channel on the web and try user-to-user interactions like typing events, reactions, and threads.
Chat Message Customization
You now have a fully functional mobile chat interface. Not bad for a couple minutes of work! Maybe you'd like to change things up a bit though? No problem! Here are four ways to customize your chat experience:
- Style the
MessageListView
using attributes (easy) - Create a custom attachment view (easy)
- Build your own views on top of the LiveData objects provided by the offline support library (advanced)
- Use the low level client to directly interact with the API
In the next sections, we'll show an example for each type of customization. We'll start by changing the colors of the chat messages to match your theme.
Open activity_channel.xml
and customize the MessageListView
with the following attributes for a green message style:
123456789101112<io.getstream.chat.android.ui.feature.messages.list.MessageListView android:id="@+id/messageListView" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toTopOf="@+id/messageComposerView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/messageListHeaderView" app:streamUiMessageBackgroundColorMine="#70AF74" app:streamUiMessageBackgroundColorTheirs="#FFFFFF" app:streamUiMessageTextColorMine="#FFFFFF" app:streamUiMessageTextColorTheirs="#000000" />
If you run the app and write a message, you'll notice that messages written by you are now green. The documentation for MessageListView details all the available customization options.
Creating Custom Attachment
There may come a time when you have requirements to include things in your chat experience that we don't provide out-of-the-box. For this purpose, we provide two main customization paths: you can either reimplement the entire ViewHolder
and display a message how you like, or you can use custom attachment views, which is a lot less work. We'll look at this latter approach now.
You could use this to embed a shopping cart in your chat, share a location, or perhaps implement a poll. For this example, we'll keep it simple and customize the preview for images shared from Imgur. We're going to render the Imgur logo over images from the imgur.com domain.
As a first step, download the Imgur logo and add it to your drawable
folder.
Next, create a new layout file called attachment_imgur.xml
:
1234567891011121314151617181920212223242526272829303132<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content"> <com.google.android.material.imageview.ShapeableImageView android:id="@+id/iv_media_thumb" android:layout_width="wrap_content" android:layout_height="0dp" android:layout_marginStart="8dp" android:layout_marginTop="4dp" android:layout_marginEnd="8dp" android:scaleType="centerCrop" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintDimensionRatio="h,1:1" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <ImageView android:id="@+id/logo" android:layout_width="150dp" android:layout_height="50dp" android:background="@drawable/imgur_logo" app:layout_constraintBottom_toBottomOf="@id/iv_media_thumb" app:layout_constraintEnd_toEndOf="@+id/iv_media_thumb" app:layout_constraintStart_toStartOf="@id/iv_media_thumb" app:layout_constraintTop_toTopOf="@id/iv_media_thumb" /> </androidx.constraintlayout.widget.ConstraintLayout>
Now we need to create a custom implementation of AttachmentFactory
. Create a new file called ImgurAttachmentFactory
and add this code:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960package com.example.chattutorial import android.view.LayoutInflater import android.view.ViewGroup import coil.load import com.example.chattutorial.databinding.AttachmentImgurBinding import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.Message import io.getstream.chat.android.ui.feature.messages.list.adapter.MessageListListenerContainer import io.getstream.chat.android.ui.feature.messages.list.adapter.viewholder.attachment.AttachmentFactory import io.getstream.chat.android.ui.feature.messages.list.adapter.viewholder.attachment.InnerAttachmentViewHolder /** A custom attachment factory to show an imgur logo if the attachment URL is an imgur image. */ class ImgurAttachmentFactory : AttachmentFactory { // Step 1 - Check whether the message contains an Imgur attachment override fun canHandle(message: Message): Boolean { val imgurAttachment = message.attachments.firstOrNull { it.isImgurAttachment() } return imgurAttachment != null } // Step 2 - Create the ViewHolder that will be used to display the Imgur logo // over Imgur attachments override fun createViewHolder( message: Message, listeners: MessageListListenerContainer?, parent: ViewGroup ): InnerAttachmentViewHolder { val imgurAttachment = message.attachments.first() { it.isImgurAttachment() } val binding = AttachmentImgurBinding .inflate(LayoutInflater.from(parent.context), null, false) return ImgurAttachmentViewHolder( imgurAttachment = imgurAttachment, binding = binding ) } private fun Attachment.isImgurAttachment(): Boolean = imageUrl?.contains("imgur") == true private class ImgurAttachmentViewHolder( binding: AttachmentImgurBinding, imgurAttachment: Attachment ) : InnerAttachmentViewHolder(binding.root) { init { binding.ivMediaThumb.apply { shapeAppearanceModel = shapeAppearanceModel .toBuilder() .setAllCornerSizes(resources.getDimension(io.getstream.chat.android.ui.R.dimen.stream_ui_selected_attachment_corner_radius)) .build() load(imgurAttachment.imageUrl) { allowHardware(false) crossfade(true) placeholder(io.getstream.chat.android.ui.R.drawable.stream_ui_picture_placeholder) } } } } }
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485package com.example.chattutorial; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.example.chattutorial.databinding.AttachmentImgurBinding; import com.google.android.material.shape.ShapeAppearanceModel; import org.jetbrains.annotations.NotNull; import coil.Coil; import coil.request.ImageRequest; import io.getstream.chat.android.models.Attachment; import io.getstream.chat.android.models.Message; import io.getstream.chat.android.ui.feature.messages.list.adapter.MessageListListenerContainer; import io.getstream.chat.android.ui.feature.messages.list.adapter.viewholder.attachment.AttachmentFactory; import io.getstream.chat.android.ui.feature.messages.list.adapter.viewholder.attachment.InnerAttachmentViewHolder; /** A custom attachment factory to show an imgur logo if the attachment URL is an imgur image. **/ public class ImgurAttachmentFactory implements AttachmentFactory { // Step 1 - Check whether the message contains an Imgur attachment @Override public boolean canHandle(@NonNull Message message) { return containsImgurAttachments(message) != null; } // Step 2 - Create the ViewHolder that will be used to display the Imgur logo // over Imgur attachments @NonNull @Override public InnerAttachmentViewHolder createViewHolder( @NonNull Message message, @Nullable MessageListListenerContainer listeners, @NonNull ViewGroup parent ) { Attachment imgurAttachment = containsImgurAttachments(message); AttachmentImgurBinding attachmentImgurBinding = AttachmentImgurBinding.inflate(LayoutInflater.from(parent.getContext()), null, false); return new ImgurAttachmentViewHolder(attachmentImgurBinding, imgurAttachment); } private Attachment containsImgurAttachments(@NotNull Message message) { for (int i = 0; i < message.getAttachments().size(); i++) { String imageUrl = message.getAttachments().get(i).getImageUrl(); if (imageUrl != null && imageUrl.contains("imgur")) { return message.getAttachments().get(i); } } return null; } private static class ImgurAttachmentViewHolder extends InnerAttachmentViewHolder { public ImgurAttachmentViewHolder(AttachmentImgurBinding binding, @Nullable Attachment imgurAttachment) { super(binding.getRoot()); ShapeAppearanceModel shapeAppearanceModel = binding.ivMediaThumb.getShapeAppearanceModel() .toBuilder() .setAllCornerSizes(binding.ivMediaThumb.getResources().getDimension(io.getstream.chat.android.ui.R.dimen.stream_ui_selected_attachment_corner_radius)) .build(); binding.ivMediaThumb.setShapeAppearanceModel(shapeAppearanceModel); if (imgurAttachment != null) { ImageRequest imageRequest = new ImageRequest.Builder(binding.getRoot().getContext()) .data(imgurAttachment.getImageUrl()) .allowHardware(false) .crossfade(true) .placeholder(io.getstream.chat.android.ui.R.drawable.stream_ui_picture_placeholder) .target(binding.ivMediaThumb) .build(); Coil.imageLoader(binding.getRoot().getContext()).enqueue(imageRequest); } } } }
Let's break down what we're doing above:
- In
canHandle
, we check whether there's an Imgur attachment in the current message. Link previews in the Chat SDK are added to the message as attachments. - If there is an Imgur attachment in the current message,
createViewHolder
will create theImgurAttachmentViewHolder
that inflates the custom Imgur layout, adds some styling (rounded corners), and then loads the Imgur image from the attachment's URL into the containedImageView
. We return this newly created View from the factory, and it'll be added to the message's UI.
Finally, we'll provide an instance of this AttachmentFactoryManager
, which includes the ImgurAttachmentFactory
to the MessageListView
component. Open ChannelActivity
and replace the TODO comment with the following:
1234// Set a view factory manager for Imgur attachments val imgurAttachmentViewFactory = ImgurAttachmentFactory() val attachmentViewFactory = AttachmentFactoryManager(listOf(imgurAttachmentViewFactory)) binding.messageListView.setAttachmentFactoryManager(attachmentViewFactory)
12345678// Set a view factory manager for Imgur attachments ImgurAttachmentFactory imgurAttachmentFactory = new ImgurAttachmentFactory(); List<ImgurAttachmentFactory> imgurAttachmentViewFactories = new ArrayList<>(); imgurAttachmentViewFactories.add(imgurAttachmentFactory); AttachmentFactoryManager attachmentFactoryManager = new AttachmentFactoryManager(imgurAttachmentViewFactories); binding.messageListView.setAttachmentFactoryManager(attachmentFactoryManager);
Your Custom Attachment View
When you run your app, you should now see the Imgur logo displayed over images from Imgur. You can test this by posting an Imgur link like this one: https://imgur.com/gallery/ro2nIC6
This was, of course, a very simple change, but you could use the same approach to implement a product preview, shopping cart, location sharing, polls, and more. You can achieve lots of your message customization goals by implementing a custom attachment View.
If you need even more customization, you can also implement custom ViewHolders for the entire message object.
Creating a Typing Status Component
If you want to build a custom UI, you can do that using the StateFlow
objects provided by our offline support library, or the events provided by our low level client. The example below will show you how to build a custom typing status component using both approaches.
First, open activity_channel.xml
and add the following TextView
above the MessageListView
. You'll also want to update the constraints for the MessageListView
.
12345678910111213141516171819<TextView android:id="@+id/typingHeaderView" android:layout_width="0dp" android:layout_height="31dp" android:background="#DDD" android:gravity="center" android:textColor="@color/black" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/messageListHeaderView" /> <io.getstream.chat.android.ui.feature.messages.list.MessageListView android:id="@+id/messageListView" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toTopOf="@+id/messageComposerView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/messageListHeaderView" />
Option 1 - Typing Status Using the Offline Library
The offline support library enables you to watch a channel and fetch an instance of ChannelState
, which provides observable StateFlow
objects for a channel such as the current user, typing state, reads statuses, etc. The full list of StateFlow
objects provided is detailed in the documentation. These make it easy to obtain data for use in your own custom UI.
Open ChannelActivity
and add the following code below Step 6, still within the onCreate
method (and an additional helper method for Java users):
123456789101112131415161718// Custom typing info header bar val nobodyTyping = "nobody is typing" binding.typingHeaderView.text = nobodyTyping // Observe typing events and update typing header depending on its state. lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { ChatClient.instance().watchChannelAsState(cid, 30) .filterNotNull() .flatMapLatest { it.typing } .collect { binding.typingHeaderView.text = when { it.users.isNotEmpty() -> it.users.joinToString(prefix = "typing: ") { user -> user.name } else -> nobodyTyping } } } }
123456789101112131415161718192021222324252627282930313233343536373839// Custom typing info header bar String nobodyTyping = "nobody is typing"; binding.typingHeaderView.setText(nobodyTyping); // Observe typing events and update typing header depending on its state. Flow<ChannelState> channelStateFlow = ChatClientExtensions.watchChannelAsState(ChatClient.instance(), cid, 30); LiveData<TypingEvent> typingEventLiveData = Transformations.switchMap( FlowExtensions.asLiveData(channelStateFlow), channelState -> FlowExtensions.asLiveData(channelState.getTyping()) ); typingEventLiveData.observe(this, typingEvent -> { String headerText; if (typingEvent.getUsers().size() != 0) { headerText = "typing: " + joinTypingUpdatesToUserNames(typingEvent); } else { headerText = nobodyTyping; } binding.typingHeaderView.setText(headerText); }); // Helper method that transforms typing updates into a string // containing typing member's names @NonNull private String joinTypingUpdatesToUserNames(@NonNull TypingEvent typingEvent) { StringBuilder joinedString = new StringBuilder(); for (int i = 0; i < typingEvent.getUsers().size(); i++) { if (i < typingEvent.getUsers().size() - 1) { joinedString.append(typingEvent.getUsers().get(i).getName()).append(", "); } else { joinedString.append(typingEvent.getUsers().get(i).getName()); } } return joinedString.toString(); }
Remember to update your imports before running the app. You should now see a small typing indicator bar just below the channel header. Note that the current user is excluded from the list of typing users.
The code is quite simple - We watch the channel in order to get ChannelState
, which contains an observable StateFlow
called typing
that provides us typing events.
If you're using Java, you'll notice an additional step that requires transforming
StateFlow
s intoLiveData
. Coroutines are not commonly used in Java code so here we transform them intoLiveData
early to keep the codebase more familiar.
To test the behaviour, you can open a client on the web, enter the same channel, and then type away!
Option 2 - Typing Status Using the Low-Level Client
The low-level client enables you to talk directly to Stream's API. This gives you the flexibility to implement any messaging UI that you want. In this case, we want to show who is typing, current user included.
The entry point for the low-level client's APIs is the ChatClient
class. In the code below, we get the ChatClient
instance, and fetch a ChannelClient
using the channel(cid)
call. This provides access to all operations on the given channel.
Then, we use subscribeFor
to listen to all TypingStart
and TypingStop
events in the current channel, and update the contents of the TextView
with the list of typing users. Note that we specify the current Activity
as the lifecycle owner to ensure that the event callbacks are removed when the Activity
is no longer active.
123456789101112131415161718192021222324// Custom typing info header bar val nobodyTyping = "nobody is typing" binding.typingHeaderView.text = nobodyTyping val currentlyTyping = mutableSetOf<String>() // Observe typing events and update typing header depending on its state. ChatClient .instance() .channel(cid) .subscribeFor( this, TypingStartEvent::class, TypingStopEvent::class ) { event -> when (event) { is TypingStartEvent -> currentlyTyping.add(event.user.name) is TypingStopEvent -> currentlyTyping.remove(event.user.name) else -> {} } binding.typingHeaderView.text = when { currentlyTyping.isNotEmpty() -> currentlyTyping.joinToString(prefix = "typing: ") else -> nobodyTyping } }
1234567891011121314151617181920212223242526272829// Custom typing info header bar TextView typingHeaderView = findViewById(R.id.typingHeaderView); String nobodyTyping = "nobody is typing"; typingHeaderView.setText(nobodyTyping); // Observe raw events through the low-level client Set<String> currentlyTyping = new HashSet<>(); ChatClient.instance() .channel(cid) .subscribeFor( this, new Class[]{TypingStartEvent.class, TypingStopEvent.class}, event -> { if (event instanceof TypingStartEvent) { User user = ((TypingStartEvent) event).getUser(); currentlyTyping.add(user.getName()); } else if (event instanceof TypingStopEvent) { User user = ((TypingStopEvent) event).getUser(); currentlyTyping.remove(user.getName()); } String typing = "nobody is typing"; if (!currentlyTyping.isEmpty()) { typing = "typing: " + TextUtils.join(", ", currentlyTyping); } typingHeaderView.setText(typing); } );
Run your app and start typing in the MessageComposerView
: you'll notice that the typing header on top updates to show that the current user is typing.
You can also use a web client to enter the same channel and generate typing events.
Congratulations!
In this Android in-app messaging tutorial, you learned how to build a fully functional chat app using Java or Kotlin. You also learned how easy it is to customize the behavior and build any type of chat or messaging experience.
Remember, you can also check out the completed app for the tutorial on GitHub.
If you want to get started on integrating chat into your own app, sign up for a free Chat trial, and get your own API key to build with!
To recap, our Android Chat SDK consists of three libraries which give you an opportunity to interact with Stream Chat APIs on a different level:
- stream-chat-android-client - The official low-level Android SDK for Stream Chat. It allows you to make API calls and receive events whenever something changes on a user or channel that you’re watching.
- stream-chat-android-offline - Builds on top of the low-level client, adds seamless offline support, optimistic UI updates (great for performance) and exposes StateFlow objects. If you want to build a fully custom UI, this library is your best starting point.
- stream-chat-android-ui-components - Provides ready-to-use UI components while also taking advantage of the offline library and low level SDK. This allows you to ship chat in your application in a matter of days.
The underlying chat API is based on Go, RocksDB, and Raft. This makes the chat experience extremely fast with response times that are often below 10ms.
Final Thoughts
In this chat app tutorial we built a fully functioning Android messaging app with our Android SDK component library. We also showed how easy it is to customize the behavior and the style of the Android chat app components with minimal code changes.
Both the chat SDK for Android and the API have plenty more features available to support more advanced use-cases such as push notifications, content moderation, rich messages and more. Please check out our Flutter tutorial too. If you want some inspiration for your app, download our free chat interface UI kit.