TUTORIAL

How to Build Android Chat In-App Messaging with Kotlin or Java

This tutorial teaches you how to use the Stream SDK to build a full-featured chat experience for your application. It includes typing indicators, reactions, threads, read state, user presence and offline support. You can use the same principles to build any type of chat or messaging experience. If you’re short on time you can also try out the sample app.

Let's start working on everything you need for building in-app messaging for your Android chat app.

Android Chat SDK Setup: Java or Kotlin

You can use the Android Chat SDK with both Java and Kotlin. If you don't have a specific requirement to use Java, we recommend Kotlin. If you run into any issues while following this tutorial, simply click the icon to leave feedback.

Add Stream Chat to Your Application Example

In order to follow this in-app messaging tutorial, you will need to ensure that Android Studio is installed.

To get started with the Android Chat SDK, open Android Studio and create a new project.

  • Select Empty Activity
  • Name the project ChatTutorial
  • Ensure the package name is com.example.chattutorial
  • Select your language - Kotlin or Java
  • Set the Minimum API level to 21
Set up demo messaging app in Android Studio

Open the project level build.gradle file and ensure that you are using Kotlin 1.4.0 or later.

buildscript {
    // the tutorial uses features added in kotlin 1.4
+   ext.kotlin_version = "1.4.10"
    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath "com.android.tools.build:gradle:4.1.0"
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

allprojects {
    repositories {
        google()
        jcenter()
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

First we'll enable ViewBinding and specify specify the ndkVersion (it’s required by Fresco, one of the libraries that StreamChat uses). Next we're are going to add the Stream Chat SDK, ViewModels and Coil to our project dependencies. Open up the app module's build.gradle script and make the following changes:

plugins {
    id 'com.android.application'
}

android {
    compileSdkVersion 30
    buildToolsVersion "30.0.2"

+   buildFeatures {
+       viewBinding true
+   }

    defaultConfig {
        applicationId "com.example.chattutorial"
        minSdkVersion 21
        targetSdkVersion 30
        versionCode 1
        versionName "1.0"
+       ndkVersion "21.3.6528147"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

dependencies {
     // add these dependencies to your java based android project
+   implementation "io.getstream:stream-chat-android:latest-version"
+   implementation "androidx.lifecycle:lifecycle-viewmodel:2.3.0-beta01"
+   implementation "io.coil-kt:coil:1.0.0"

    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'com.google.android.material:material:1.2.1'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.3'
    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}
plugins {
    id 'com.android.application'
    id 'kotlin-android'
}

android {
    compileSdkVersion 30
    buildToolsVersion "30.0.2"

+   buildFeatures {
+       viewBinding true
+   }

    defaultConfig {
        applicationId "com.example.chattutorial"
        minSdkVersion 21
        targetSdkVersion 30
        versionCode 1
        versionName "1.0"
+       ndkVersion "21.3.6528147"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
}

dependencies {

     // add these dependencies to your kotlin based android project
+   implementation "io.getstream:stream-chat-android:latest-version"
+   implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0-beta01"
+   implementation "io.coil-kt:coil:1.0.0"
+   implementation 'androidx.activity:activity-ktx:1.1.0'

    implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    implementation 'androidx.core:core-ktx:1.3.2'
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'com.google.android.material:material:1.2.1'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.3'
    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}

Note: After you edit your Gradle files, make sure to sync the project (Android Studio will prompt you for this) with the new changes.

Channel List

Stream provides a low-level client, an offline support library, and custom views to help you quickly build your messaging interface. In this section we'll use the views to quickly display a channel list.

Let's get started by rendering a list of channels. Open up activity_main.xml, in Android studio you can find it under res->layout->activity_main.xml. Change the contents of the file to this:

<?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">

    <com.getstream.sdk.chat.view.channels.ChannelsView
        android:id="@+id/channelsView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:streamChannelPreviewLayout="@layout/stream_item_channel"
        app:streamReadStateAvatarHeight="15dp"
        app:streamReadStateAvatarWidth="15dp"
        app:streamReadStateTextSize="9sp"
        app:streamShowReadState="true" />

</androidx.constraintlayout.widget.ConstraintLayout>

Note how the above layout renders a ChannelsView. As a next step open up MainActivity (java->com.example.chattutorial->MainActivity) and replace the file's contents with the following code:

package com.example.chattutorial;

import android.os.Bundle;

import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.ViewModelProvider;

import com.getstream.sdk.chat.ChatUI;
import com.getstream.sdk.chat.view.channels.ChannelsView;
import com.getstream.sdk.chat.viewmodel.channels.ChannelsViewModel;
import com.getstream.sdk.chat.viewmodel.channels.ChannelsViewModelBinding;
import com.getstream.sdk.chat.viewmodel.factory.ChannelsViewModelFactory;

import org.jetbrains.annotations.Nullable;

import io.getstream.chat.android.client.ChatClient;
import io.getstream.chat.android.client.models.Filters;
import io.getstream.chat.android.client.models.User;
import io.getstream.chat.android.client.utils.FilterObject;
import io.getstream.chat.android.livedata.ChatDomain;
import kotlin.Unit;

import static java.util.Collections.singletonList;

public final class MainActivity extends AppCompatActivity {

    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // Step 1 - Set up the client for API calls, the domain for offline storage and the UI components
        ChatClient client = new ChatClient.Builder("b67pax5b2wdq", getApplicationContext()).build();
        ChatDomain domain = new ChatDomain.Builder(client, getApplicationContext()).build();
        new ChatUI.Builder(client, domain, getApplicationContext()).build();

        // Step 2 - Authenticate and connect the user
        User user = new User();
        user.setId("summer-brook-2");
        user.getExtraData().put("name", "Paranoid Android");
        user.getExtraData().put("image", "https://bit.ly/2TIt8NR");

        client.setUser(
                user,
                "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VtbWVyLWJyb29rLTIifQ.CzyOx8kgrc61qVbzWvhV1WD3KPEo5ZFZH-326hIdKz0",
                null
        );

        // Step 3 - 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()))
        );
        ChannelsViewModelFactory factory = new ChannelsViewModelFactory(
                filter,
                ChannelsViewModel.DEFAULT_SORT
        );
        ChannelsViewModel channelsViewModel = new ViewModelProvider(this, factory).get(ChannelsViewModel.class);

        // Step 4 - Connect the ChannelsViewModel to the ChannelsView, loose coupling makes it easy to customize
        ChannelsView channelsView = findViewById(R.id.channelsView);
        ChannelsViewModelBinding.bind(channelsViewModel, channelsView, this);
        channelsView.setOnChannelClickListener((channel -> {
            // TODO: Start Channel activity
            return Unit.INSTANCE;
        }));
    }
}
package com.example.chattutorial

import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.example.chattutorial.databinding.ActivityMainBinding
import com.getstream.sdk.chat.ChatUI
import com.getstream.sdk.chat.viewmodel.channels.ChannelsViewModel
import com.getstream.sdk.chat.viewmodel.channels.bindView
import com.getstream.sdk.chat.viewmodel.factory.ChannelsViewModelFactory
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.client.logger.ChatLogLevel
import io.getstream.chat.android.client.models.Filters
import io.getstream.chat.android.client.models.User
import io.getstream.chat.android.livedata.ChatDomain

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // Step 1 - Set up the client for API calls, the domain for offline storage and the UI components
        val client =
            ChatClient.Builder("b67pax5b2wdq", applicationContext).logLevel(ChatLogLevel.ALL)
                .build()
        val domain = ChatDomain.Builder(client, applicationContext).build()
        ChatUI.Builder(client, domain, applicationContext).build()

        // Step 2 - Authenticate and connect the user
        val user = User("summer-brook-2").apply {
            extraData["name"] = "Paranoid Android"
            extraData["image"] = "https://bit.ly/2TIt8NR"
        }
        client.setUser(
            user = user,
            token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VtbWVyLWJyb29rLTIifQ.CzyOx8kgrc61qVbzWvhV1WD3KPEo5ZFZH-326hIdKz0"
        )

        // Step 3 - 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 = ChannelsViewModelFactory(filter, ChannelsViewModel.DEFAULT_SORT)
        val viewModel: ChannelsViewModel by viewModels { viewModelFactory }

        // Step 4 - Connect the ChannelsViewModel to the ChannelsView, loose coupling makes it easy to customize
        viewModel.bindView(findViewById(R.id.channelsView), this)
        binding.channelsView.setOnChannelClickListener { channel ->
            // TODO: Start Channel activity
        }
    }
}

A Working Channel List

Run your application and you should see a channel list interface as shown on the right.

Note: If your application shows an infinite loading state try to uninstall the app on your emulator and reinstall it. This error can be caused by the caching of permissions at the Android level.

Let's have a quick look at the source code shown above:

  • In step 1 we start by creating a connection to Stream by initializing the ChatClient using your api key. Next, we create a ChatDomain for offline storage and initialize the ChatUI component. For a production app, we recommend configuring these 3 classes in your Application class.
  • In step 2 we create a User and call setUser on the ChatClient. This authenticates the current user. Because this is a tutorial, we use a pre-generated user token to keep things easy to follow. In a real-world application, your authentication backend would generate such a token at login / signup and hand it over to the mobile app. More information about this is available on the Chat API docs.
  • In the third step, we configure the ChannelsViewModelFactory with a filter and a sort option. We’re using the default sort option which orders the channels by "last updated at" time - so the most recently used channels appear on top. For the filter, we’re selecting all channels of type “messaging” where the current user is a member. The documentation about querying channels covers this in more detail.
  • The last and 4th step is to bind our ChannelsView to the ChannelsViewModel by calling the bindView function. The View & ViewModel are loosely coupled which makes it easy to customize them.

That’s it - most of the logic for this Activity is in the ChannelsViewModel class. The ViewModel uses the low-level client and the offline support library to keep all chat information in sync, and exposes data to the View via LiveData. The Channels list is automatically fetched after setting the user.

Channel List Chat interface for Java / Kotlin on Android

Message List

As a next step, let's open up one of these channels and start sending messages. To do this we'll leverage the ChannelHeaderView, MessageListView, and MessageInputView UI components. Again, these custom views are just a starting point. You can always use the lower level API to build your views from scratch.

Create new empty activity (File > New > Activity > Empty Activity) and name it ChannelActivity.

Next, open up activity_channel.xml and update the layout to match this example:

<?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">

   <com.getstream.sdk.chat.view.ChannelHeaderView
       android:id="@+id/channelHeaderView"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent" />

   <com.getstream.sdk.chat.view.MessageListView
       android:id="@+id/messageListView"
       android:layout_width="0dp"
       android:layout_height="0dp"
       android:clipToPadding="false"
       android:paddingBottom="16dp"
       app:layout_constraintBottom_toTopOf="@+id/messageInputView"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/channelHeaderView" />
  
   <com.getstream.sdk.chat.view.messageinput.MessageInputView
       android:id="@+id/messageInputView"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Note that the ConstraintLayout contains a ChannelHeaderView, MessageListView, and a MessageInputView. Next, we'll want to replace the code in ChannelActivity with the following:

package com.example.chattutorial;

import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.widget.ProgressBar;

import androidx.activity.OnBackPressedCallback;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.ViewModelProvider;

import com.getstream.sdk.chat.view.ChannelHeaderView;
import com.getstream.sdk.chat.view.MessageListView;
import com.getstream.sdk.chat.view.messageinput.MessageInputView;
import com.getstream.sdk.chat.viewmodel.ChannelHeaderViewModel;
import com.getstream.sdk.chat.viewmodel.ChannelHeaderViewModelBinding;
import com.getstream.sdk.chat.viewmodel.MessageInputViewModel;
import com.getstream.sdk.chat.viewmodel.MessageInputViewModelBinding;
import com.getstream.sdk.chat.viewmodel.factory.ChannelViewModelFactory;
import com.getstream.sdk.chat.viewmodel.messages.MessageListViewModel;
import com.getstream.sdk.chat.viewmodel.messages.MessageListViewModel.Mode.Normal;
import com.getstream.sdk.chat.viewmodel.messages.MessageListViewModel.Mode.Thread;
import com.getstream.sdk.chat.viewmodel.messages.MessageListViewModel.State.NavigateUp;
import com.getstream.sdk.chat.viewmodel.messages.MessageListViewModelBinding;

import io.getstream.chat.android.client.models.Channel;
import kotlin.Unit;

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);
        setContentView(R.layout.activity_channel);
        String cid = getIntent().getStringExtra(CID_KEY);
        if (cid == null) {
            throw new IllegalStateException("Specifying a channel id is required when starting ChannelActivity");
        }

        // Step 0 - Get View references
        MessageListView messageListView = findViewById(R.id.messageListView);
        ProgressBar progressBar = findViewById(R.id.progressBar);
        ChannelHeaderView channelHeaderView = findViewById(R.id.channelHeaderView);
        MessageInputView messageInputView = findViewById(R.id.messageInputView);

        // Step 1 - Create 3 separate ViewModels for the views so it's easy to customize one of the components
        ChannelViewModelFactory factory = new ChannelViewModelFactory(cid);
        ViewModelProvider provider = new ViewModelProvider(this, factory);
        ChannelHeaderViewModel channelHeaderViewModel = provider.get(ChannelHeaderViewModel.class);
        MessageListViewModel messageListViewModel = provider.get(MessageListViewModel.class);
        MessageInputViewModel messageInputViewModel = provider.get(MessageInputViewModel.class);

        // TODO set custom AttachmentViewHolderFactory

        // Step 2 - Bind the view and ViewModels, they are loosely coupled so it's easy to customize
        ChannelHeaderViewModelBinding.bind(channelHeaderViewModel, channelHeaderView, this);
        MessageListViewModelBinding.bind(messageListViewModel, messageListView, this);
        MessageInputViewModelBinding.bind(messageInputViewModel, messageInputView, this);

        // Step 3 - Let the message input know when we open a thread
        messageListViewModel.getMode().observe(this, mode -> {
            if (mode instanceof Thread) {
                messageInputViewModel.setActiveThread(((Thread) mode).getParentMessage());
            } else if (mode instanceof Normal) {
                messageInputViewModel.resetThread();
            }
        });
        // Step 4 - Let the message input know when we are editing a message
        messageListView.setOnMessageEditHandler(message -> {
            messageInputViewModel.getEditMessage().postValue(message);
            return Unit.INSTANCE;
        });

        // Step 5 - Handle navigate up state
        messageListViewModel.getState().observe(this, state -> {
            if (state instanceof NavigateUp) {
                finish();
            }
        });

        // Step 6 - Handle back button behaviour correctly when you're in a thread
        channelHeaderView.setOnBackClick(() -> {
            messageListViewModel.onEvent(MessageListViewModel.Event.BackButtonPressed.INSTANCE);
            return Unit.INSTANCE;
        });
        getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
            @Override
            public void handleOnBackPressed() {
                channelHeaderView.getOnBackClick().invoke();
            }
        });
    }
}
package com.example.chattutorial

import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.example.chattutorial.databinding.ActivityChannelBinding
import com.getstream.sdk.chat.viewmodel.ChannelHeaderViewModel
import com.getstream.sdk.chat.viewmodel.MessageInputViewModel
import com.getstream.sdk.chat.viewmodel.bindView
import com.getstream.sdk.chat.viewmodel.factory.ChannelViewModelFactory
import com.getstream.sdk.chat.viewmodel.messages.MessageListViewModel
import com.getstream.sdk.chat.viewmodel.messages.MessageListViewModel.Mode.Normal
import com.getstream.sdk.chat.viewmodel.messages.MessageListViewModel.Mode.Thread
import com.getstream.sdk.chat.viewmodel.messages.MessageListViewModel.State.NavigateUp
import com.getstream.sdk.chat.viewmodel.messages.bindView
import io.getstream.chat.android.client.models.Channel

class ChannelActivity : AppCompatActivity() {

    private lateinit var binding: ActivityChannelBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityChannelBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val cid = checkNotNull(intent.getStringExtra(CID_KEY)) {
            "Specifying a channel id is required when starting ChannelActivity"
        }

        // Step 1 - Create 3 separate ViewModels for the views so it's easy to customize one of the components
        val factory = ChannelViewModelFactory(cid)
        val channelHeaderViewModel: ChannelHeaderViewModel by viewModels { factory }
        val messageListViewModel: MessageListViewModel by viewModels { factory }
        val messageInputViewModel: MessageInputViewModel by viewModels { factory }

        // TODO set custom AttachmentViewHolderFactory

        // Step 2 - Bind the view and ViewModels, they are loosely coupled so it's easy to customize
        channelHeaderViewModel.bindView(binding.channelHeaderView, this)
        messageListViewModel.bindView(binding.messageListView, this)
        messageInputViewModel.bindView(binding.messageInputView, this)

        // Step 3 - Let the message input know when we open a thread
        // Note: support for listening to LiveData like this was added in kotlin 1.4, upgrade kotlin if you run into issues
        messageListViewModel.mode.observe(this) { mode ->
            when (mode) {
                is Thread -> messageInputViewModel.setActiveThread(mode.parentMessage)
                is Normal -> messageInputViewModel.resetThread()
            }
        }

        // Step 4 - Let the message input know when we are editing a message
        binding.messageListView.setOnMessageEditHandler {
            messageInputViewModel.editMessage.postValue(it)
        }

        // Step 5 - Handle navigate up state
        messageListViewModel.state.observe(this) { state ->
            if (state is NavigateUp) {
                finish()
            }
        }

        // Step 6 - Handle back button behaviour correctly when you're in a thread
        binding.channelHeaderView.onBackClick = {
            messageListViewModel.onEvent(MessageListViewModel.Event.BackButtonPressed)
        }
        onBackPressedDispatcher.addCallback(this) {
            binding.channelHeaderView.onBackClick()
        }
    }

    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)
    }
}

Lastly, we want to launch the ChannelActivity when you click a channel. Open MainActivity and make the following changes:

channelsView.setOnChannelClickListener((channel -> {
-    // TODO: Start Channel activity
+    startActivity(ChannelActivity.newIntent(this, channel));
     return Unit.INSTANCE;
}));
- // TODO: Start Channel activity
+    startActivity(ChannelActivity.newIntent(this, channel))

Note: 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.)

If you hit run and click a channel you should see a chat interface as shown on the right.

ChannelActivity has quite a few steps, so we’ll take a bit of time to review what’s going on.

  • Step 1 sets up 3 ViewModels:
  • In step 2 we bind these ViewModels to their Views. This loose coupling between components makes it easy for you to customize things or only use some of our components. The documentation about Binding Views and View Models explains how this works.
  • In step 3 and 4 we connect the message list behaviour with the message input. The MessageInput needs to know when you’re editing a message or are in an open thread.
  • In step 5 and 6 we set up the back button handler to work correctly. In the case of a thread it will go back to the channel, and when you’re on the channel it will navigate back up to the channel list.

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:

  1. Offline support: you can still send messages, edit messages and send reactions while offline
  2. Link previews: will be generated automatically when you send a link
  3. Commands: type / to use commands like /giphy
  4. Reactions: long-press on messages to add a reaction
  5. Attach images: use the plus button ⨁ in the composer to attach images or files
  6. Edit message: long-press on your messages to open the menu with the Edit button
  7. Typing events: you’ll see typing events at the bottom of messages when someone starts typing
  8. Read state: note the small read indicators that show how far other user have read
  9. Threads: you can create threads to any message in a channel, just tap on a message and hit Reply to enter the thread view

You’ll also notice that the chat loads 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. If you are interested, you can open the same channel on the web and try user-to-user interactions like typing events, reactions, and threads.

Custom Android chat bubble example with Java / Kotlin

Chat Message Customization

You now have a fully functional chat interface, not bad for a couple minutes of work. There are four ways to customize your chat experience:

  1. Style the MessageListView using attributes (easy)
  2. Create a custom attachment type (easy)
  3. Build your own views on top of the LiveData objects provided by the offline support library, which allows you to fully customize the UI for any use case (moderately difficult)
  4. 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:

 <com.getstream.sdk.chat.view.MessageListView
    android:id="@+id/messageListView"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:clipToPadding="false"
    android:paddingBottom="16dp"
    app:layout_constraintBottom_toTopOf="@+id/messageInputView"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/typingHeaderView"
+   android:background="#f3f5f8"
+   app:streamMessageTextColorMine="#FFF"
+   app:streamMessageBackgroundColorMine="#70af74"
+   app:streamMessageTextColorTheirs="#000"
+   app:streamMessageBackgroundColorTheirs="#FFF"
    />

If you run the app and write a message, you'll notice that messages written by you are now green. The documentation for the MessageListView details all the available customization options for this View.

Message List Chat interface for Java / Kotlin on Android

A custom attachment type

Ok, let's try to make a more complicated change and implement a custom attachment ViewHolder. You could use this approach 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 layout 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 an XML layout called list_item_attach_imgur.xml

<?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.getstream.sdk.chat.utils.roundedImageView.PorterShapeImageView
        android:id="@+id/iv_media_thumb"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:layout_marginStart="1dp"
        android:layout_marginTop="1dp"
        android:layout_marginEnd="1dp"
        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="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Now we need to implement the AttachmentViewHolderImgur class. Create a new file called AttachmentViewHolderImgur and add this code:

package com.example.chattutorial

import android.view.LayoutInflater
import android.view.ViewGroup
import coil.load
import com.example.chattutorial.databinding.ListItemAttachImgurBinding
import com.getstream.sdk.chat.adapter.AttachmentListItem
import com.getstream.sdk.chat.adapter.MessageListItem
import com.getstream.sdk.chat.adapter.viewholder.attachment.BaseAttachmentViewHolder
import com.getstream.sdk.chat.view.MessageListView

class AttachmentViewHolderImgur(
    parent: ViewGroup,
    private val bubbleHelper: MessageListView.BubbleHelper,
    private val messageItem: MessageListItem.MessageItem,
    private val binding: ListItemAttachImgurBinding = ListItemAttachImgurBinding.inflate(
        LayoutInflater.from(parent.context), parent, false
    )
) : BaseAttachmentViewHolder(binding.root) {

    override fun bind(attachmentListItem: AttachmentListItem) {
        val background = bubbleHelper.getDrawableForAttachment(
            messageItem.message,
            messageItem.isMine,
            messageItem.positions,
            attachmentListItem.attachment
        )
        binding.ivMediaThumb.setShape(context, background)

        binding.ivMediaThumb.load(attachmentListItem.attachment.thumbUrl) {
            allowHardware(false)
        }
    }
}
package com.example.chattutorial;

import android.graphics.drawable.Drawable;
import android.view.LayoutInflater;
import android.view.ViewGroup;

import com.getstream.sdk.chat.adapter.AttachmentListItem;
import com.getstream.sdk.chat.adapter.MessageListItem.MessageItem;
import com.getstream.sdk.chat.adapter.viewholder.attachment.BaseAttachmentViewHolder;
import com.getstream.sdk.chat.utils.roundedImageView.PorterShapeImageView;
import com.getstream.sdk.chat.view.MessageListView.BubbleHelper;

import org.jetbrains.annotations.NotNull;

import coil.Coil;
import coil.request.ImageRequest;

public class AttachmentViewHolderImgur extends BaseAttachmentViewHolder {
    private final BubbleHelper bubbleHelper;
    private final MessageItem messageItem;
    private final PorterShapeImageView imageView;

    public AttachmentViewHolderImgur(
            ViewGroup parent,
            BubbleHelper bubbleHelper,
            MessageItem messageItem
    ) {
        super(LayoutInflater.from(parent.getContext())
                .inflate(R.layout.list_item_attach_imgur, parent, false));
        this.bubbleHelper = bubbleHelper;
        this.messageItem = messageItem;
        imageView = itemView.findViewById(R.id.iv_media_thumb);
    }

    @Override
    public void bind(@NotNull AttachmentListItem attachmentListItem) {
        Drawable background = bubbleHelper.getDrawableForAttachment(
                messageItem.getMessage(),
                messageItem.isMine(),
                messageItem.getPositions(),
                attachmentListItem.getAttachment());
        imageView.setShape(getContext(), background);

        ImageRequest request = new ImageRequest.Builder(getContext())
                .data(attachmentListItem.getAttachment().getThumbUrl())
                .allowHardware(false)
                .target(imageView)
                .build();
        Coil.imageLoader(getContext()).enqueue(request);
    }
}

Next, we'll create a new file called MyAttachmentViewHolderFactory and enter the following code:

package com.example.chattutorial;

import android.view.ViewGroup;

import com.getstream.sdk.chat.adapter.AttachmentListItem;
import com.getstream.sdk.chat.adapter.AttachmentViewHolderFactory;
import com.getstream.sdk.chat.adapter.MessageListItem;
import com.getstream.sdk.chat.adapter.viewholder.attachment.BaseAttachmentViewHolder;
import com.getstream.sdk.chat.view.MessageListViewStyle;

import org.jetbrains.annotations.NotNull;

public class MyAttachmentViewHolderFactory extends AttachmentViewHolderFactory {
    private static final int IMGUR_TYPE = 0;

    @Override
    public int getAttachmentViewType(@NotNull AttachmentListItem attachmentListItem) {
        String imageUrl = attachmentListItem.getAttachment().getImageUrl();
        if (imageUrl != null && imageUrl.contains("imgur")) {
            return IMGUR_TYPE;
        }
        return super.getAttachmentViewType(attachmentListItem);
    }

    @NotNull
    @Override
    public BaseAttachmentViewHolder createAttachmentViewHolder(
            @NotNull ViewGroup parent,
            int viewType,
            @NotNull MessageListViewStyle style,
            @NotNull MessageListItem.MessageItem messageItem) {

        if (viewType == IMGUR_TYPE) {
            return new AttachmentViewHolderImgur(parent, bubbleHelper, messageItem);
        }
        return super.createAttachmentViewHolder(parent, viewType, style, messageItem);
    }
}
package com.example.chattutorial

import android.view.ViewGroup
import com.getstream.sdk.chat.adapter.*
import com.getstream.sdk.chat.adapter.viewholder.attachment.BaseAttachmentViewHolder
import com.getstream.sdk.chat.view.MessageListViewStyle

class MyAttachmentViewHolderFactory : AttachmentViewHolderFactory() {

    override fun getAttachmentViewType(attachmentItem: AttachmentListItem): Int {
        val imageUrl = attachmentItem.attachment.imageUrl ?: ""
        return when {
            imageUrl.contains("imgur") -> IMGUR_TYPE
            else -> super.getAttachmentViewType(attachmentItem)
        }
    }

    override fun createAttachmentViewHolder(
        parent: ViewGroup,
        viewType: Int,
        style: MessageListViewStyle,
        messageItem: MessageListItem.MessageItem
    ): BaseAttachmentViewHolder {
        return when (viewType) {
            IMGUR_TYPE -> AttachmentViewHolderImgur(parent, bubbleHelper, messageItem)
            else -> super.createAttachmentViewHolder(parent, viewType, style, messageItem)
        }
    }

    companion object {
        private const val IMGUR_TYPE = 0
    }
}

As a last step, we'll hook up your new MyMessageViewHolderFactory to the MessageListView component. Open ChannelActivity and replace the TODO comment with the following line:

-    // TODO set custom AttachmentViewHolderFactory
+    // Set custom AttachmentViewHolderFactory
+    messageListView.setAttachmentViewHolderFactory(new MyAttachmentViewHolderFactory());
-    // TODO set custom AttachmentViewHolderFactory
+    // set custom AttachmentViewHolderFactory
+    binding.messageListView.setAttachmentViewHolderFactory(MyAttachmentViewHolderFactory())

Your custom ViewHolder

Run your app, where 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, or a poll. You can typically achieve what you want by using a custom attachment type. Implementing a custom attachment type is easier than changing the full message rendering.

Imgur Logo overlay on the in-app messaging chat interface

Creating your own Channel Header component

If you want to build a custom UI, you can do that using the LiveData 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 indicator using both approaches.

First off, open activity_channel.xml and add the following TextView above the MessageListView. You'll also want to update the constraint for the message list view.

+<TextView
+    android:id="@+id/typingHeaderView"
+    android:layout_width="match_parent"
+    android:layout_height="31dp"
+    android:background="#CCCCCC"
+    android:gravity="center"
+    app:layout_constraintEnd_toEndOf="parent"
+    app:layout_constraintStart_toStartOf="parent"
+    app:layout_constraintTop_toBottomOf="@+id/channelHeaderView" />

<com.getstream.sdk.chat.view.MessageListView
    android:id="@+id/messageListView"
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:layout_constraintBottom_toTopOf="@+id/messageInputView"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
-   app:layout_constraintTop_toBottomOf="@+id/channelHeaderView"
+   app:layout_constraintTop_toBottomOf="@+id/typingHeaderView"
    android:clipToPadding="false"
    android:paddingBottom="16dp"
    android:background="#f3f5f8"
    app:streamMessageTextColorMine="#FFF"
    app:streamMessageBackgroundColorMine="#70af74"
    app:streamMessageTextColorTheirs="#000"
    app:streamMessageBackgroundColorTheirs="#FFF"
    />

Option 1 - Creating a Channel Header using the Offline lib

The offline support library exposes LiveData objects for a channel such as messages, typing, reads, etc. The full list of LiveData objects provided is listed on the documentation. These LiveData objects make it easy to build your own UI. Open ChannelActivity and add the following code below step 4:

// Custom typing info header bar
TextView typingHeaderView = findViewById(R.id.typingHeaderView);
String nobodyTyping = "nobody is typing";
typingHeaderView.setText(nobodyTyping);

// Obtain a ChannelController
ChatDomain.instance().getUseCases().getGetChannelController().invoke(cid).enqueue((result) -> {
    ChannelController channelController = result.data();

    // Observe typing users
    channelController.getTyping().observe(this, typingState -> {
        if (typingState.getUsers().isEmpty()) {
            typingHeaderView.setText(nobodyTyping);
        } else {
            List<String> userNames = new LinkedList<>();
            for (User user : typingState.getUsers()) {
                userNames.add((String) user.getExtraData().get("name"));
            }
            String typing = "typing: " + TextUtils.join(", ", userNames);
            typingHeaderView.setText(typing);
        }
    });
    return Unit.INSTANCE;
});
// Custom typing info header bar
val nobodyTyping = "nobody is typing"
binding.typingHeaderView.text = nobodyTyping

// Obtain a ChannelController
ChatDomain.instance().useCases.getChannelController(cid).enqueue { channelControllerResult ->
    if (channelControllerResult.isSuccess) {

        // Observe typing users
        channelControllerResult.data().typing.observe(this) { typingState ->
            binding.typingHeaderView.text = when {
                typingState.users.isNotEmpty() -> typingState.users.joinToString(prefix = "typing: ") { user -> user.name }
                else -> nobodyTyping
            }
        }
    }
}

Remember to update your imports before running the app. You'll 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 are invoking the getChannelController use case which returns the ChannelController. The controller exposes a typing LiveData object. Next, we observe the LiveData object and update the text in the TextView we've added.

Option 2 - Creating a Channel Header 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/chat UI that you want. In our case, we want to show a message on the Channel Header view when someone is typing no matter if it is the current user or not.

The code below uses the channel(cid).subscribeFor call to listen to all TypingStart and TypingStop events. It updates the contents of the TextView with the list of typing users. Note how it’s specifying the current Activity as the lifecycle owner to ensure that the callbacks are removed when the Activity is no longer active.

// Custom typing info header bar
TextView typingHeaderView = findViewById(R.id.typingHeaderView);
String nobodyTyping = "nobody is typing";
typingHeaderView.setText(nobodyTyping);

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();
                        String name = (String) user.getExtraData().get("name");
                        currentlyTyping.add(name);
                    } else if (event instanceof TypingStopEvent) {
                        User user = ((TypingStopEvent) event).getUser();
                        String name = (String) user.getExtraData().get("name");
                        currentlyTyping.remove(name);
                    }

                    String typing = "nobody is typing";
                    if (!currentlyTyping.isEmpty()) {
                        typing = "typing: " + TextUtils.join(", ", currentlyTyping);
                    }
                    typingHeaderView.setText(typing);
                    return Unit.INSTANCE;
                });
// Custom typing info header bar
val nobodyTyping = "nobody is typing"
binding.typingHeaderView.text = nobodyTyping

val currentlyTyping = mutableSetOf<String>()

ChatClient
    .instance()
    .channel(cid)
    .subscribeFor(this, TypingStartEvent::class.java, TypingStopEvent::class.java
    ) { event ->
        @Suppress("NON_EXHAUSTIVE_WHEN_ON_SEALED_CLASS")
        when (event) {
            is TypingStartEvent -> currentlyTyping.add(event.user.extraData["name"] as String)
            is TypingStopEvent -> currentlyTyping.remove(event.user.extraData["name"] as String)
        }

        binding.typingHeaderView.text = when {
            currentlyTyping.isNotEmpty() -> currentlyTyping.joinToString(prefix = "typing: ")
            else -> nobodyTyping
        }
    }

When you run your chat app you should now see a little typing indicator above the channel. If you type in something the message input you'll notice that it updates to show that the current user is typing.

In-app example of typing into an Android messaging window

Final thoughts

In this Android Chat App tutorial, you learned how to build a fully functional chat in-app using Java or Kotlin. We also showed how easy it is to customize the behavior and build any type of chat or messaging experience.

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 LiveData objects. If you want to build a fully custom UI, this library is your best starting point.
  • stream-chat-android - Provides ready to use UI components, and integrates the offline library and low level SDK. This allows you to ship chat in your application in 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. Stream powers activity feeds and chat for over a billion end-users.

Both the Java Chat SDK and the Chat API have plenty more features available to support more advanced use-cases such as push notifications, content moderation, rich messages, and more. Feel free to contact us if you have any questions.

Next Steps

In addition to this tutorial you might also want to learn about the Stream Chat API, the iOS Swift Chat SDK, Python Chat, React Chat and React Native Chat, along with our free Chat UI Kit for more resources on building your app with Stream.