TUTORIAL

How to Build an Android Chat App with Java or Kotlin

Learn how to use our Android Chat SDK to create a polished messaging experience with typing indicators, read state, attachments, reactions, user presence, and threads.

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

Android Chat SDK Setup: Java or Kotlin

You can use the Android Chat SDK with both Java or Kotlin, this tutorial has code examples in both languages. 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 messages app 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
  • Rename the project ChatTutorial
  • Select your language - Kotlin or Java
  • Select Minimum API level to 29 (Chat SDK is compatible with API level 21 or higher)
  • Enable “Use androidx.*artifacts” if it isn’t already enabled.
setup android app screenshot

Next, we are going to add the StreamChat SDK to our project dependencies. Open the project build.gradle file and replace its content with this so that we can use jitpack.io to install our requirements.

buildscript {
    ext.kotlin_version = '1.3.61'
    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.5.3'      
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

allprojects {
    repositories {
        google()
        jcenter()
+       maven {
+           url "https://jitpack.io"
+       }
    }
}

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

Next step is to change the application build.gradle script. Here we are going to add StreamChat, Glide, Lifecycle extensions, and the androidx recyclerview to the dependencies. We'll also enable lambda expressions by targeting Java 8 and enable data binding.

apply plugin: 'com.android.application'

apply plugin: 'kotlin-android'

apply plugin: 'kotlin-android-extensions'

android {
    compileSdkVersion 29
    buildToolsVersion "29.0.2"
    defaultConfig {
        applicationId "com.example.chattutorial"
        minSdkVersion 29
        targetSdkVersion 29
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }

+    dataBinding {
+        enabled = true
+    }

+    compileOptions {
+        sourceCompatibility JavaVersion.VERSION_1_8
+        targetCompatibility JavaVersion.VERSION_1_8
+    }
}

dependencies {
+    implementation 'com.github.getstream:stream-chat-android:3.6.5'
+    implementation 'com.github.bumptech.glide:glide:4.11.0'
+    implementation 'androidx.recyclerview:recyclerview:1.1.0'
+    implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'

    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'androidx.appcompat:appcompat:1.1.0'
    implementation 'androidx.core:core-ktx:1.2.0'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test.ext:junit:1.1.0'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}

Note: When you save 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, livedata objects and custom views to help you quickly build your chat interface. (We're also thinking about adding RXJava support, so be sure to reach out if that's something you're interested in)

Let's get started by rendering a list of channels. Open your 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"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="viewModel"
            type="com.getstream.sdk.chat.viewmodel.ChannelListViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context="com.example.chattutorial.MainActivity">


        <com.getstream.sdk.chat.view.ChannelListView
            android:id="@+id/channelList"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_marginBottom="10dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:streamReadStateAvatarHeight="15dp"
            app:streamReadStateAvatarWidth="15dp"
            app:streamReadStateTextSize="9sp"
            app:streamShowReadState="true" />

        <ProgressBar
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:isGone="@{!safeUnbox(viewModel.loading)}"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <ProgressBar
            android:layout_width="25dp"
            android:layout_height="25dp"
            android:layout_marginBottom="16dp"
            app:isGone="@{!safeUnbox(viewModel.loadingMore)}"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

Note how the above layout renders a ChannelListView and 2 progress bars. As a next step open up MainActivity (java->com.example.chattutorial->MainActivity) and replace it with the following code:

package com.example.chattutorial;

import android.os.Bundle;

import androidx.appcompat.app.AppCompatActivity;
import androidx.databinding.DataBindingUtil;
import androidx.lifecycle.ViewModelProviders;

import com.example.chattutorial.databinding.ActivityMainBinding;
import com.getstream.sdk.chat.StreamChat;
import com.getstream.sdk.chat.enums.FilterObject;
import com.getstream.sdk.chat.rest.User;
import com.getstream.sdk.chat.rest.core.Client;
import com.getstream.sdk.chat.viewmodel.ChannelListViewModel;

import java.util.HashMap;

import static com.getstream.sdk.chat.enums.Filters.and;
import static com.getstream.sdk.chat.enums.Filters.in;
import static com.getstream.sdk.chat.enums.Filters.eq;

import android.content.Intent;

/**
 * This activity shows a list of channels
 */
public class MainActivity extends AppCompatActivity {
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {

        super.onCreate(savedInstanceState);

        // setup the client using the example API key
        // normal you would call init in your Application class and not the activity
        StreamChat.init("b67pax5b2wdq", this.getApplicationContext());
        Client client = StreamChat.getInstance(this.getApplication());
        HashMap<String, Object> extraData = new HashMap<>();
        extraData.put("name", "Paranoid Android");
        extraData.put("image", "https://bit.ly/2TIt8NR");
        User currentUser = new User("fragrant-mud-6", extraData);
        // User token is typically provided by your server when the user authenticates
        client.setUser(currentUser, "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiZnJhZ3JhbnQtbXVkLTYifQ.jSikUm1uyyGnKc1LiCOE8vwbGCx69PvgrA2ROa8XTls");

        // we're using data binding in this example
        ActivityMainBinding binding =
                DataBindingUtil.setContentView(this, R.layout.activity_main);
        // Specify the current activity as the lifecycle owner.
        binding.setLifecycleOwner(this);

        // most the business logic for chat is handled in the ChannelListViewModel view model
        ChannelListViewModel viewModel = ViewModelProviders.of(this).get(ChannelListViewModel.class);
        binding.setViewModel(viewModel);
        binding.channelList.setViewModel(viewModel, this);

        // query all channels of type messaging
        FilterObject filter = and(eq("type", "messaging"), in("members", "fragrant-mud-6"));
        viewModel.setChannelFilter(filter);

        // click handlers for clicking a user avatar or channel
        binding.channelList.setOnChannelClickListener(channel -> {
            // open the channel activity
        });
        binding.channelList.setOnUserClickListener(user -> {
            // open your user profile
        });

    }
}
package com.example.chattutorial

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider
import com.example.chattutorial.databinding.ActivityMainBinding
import com.getstream.sdk.chat.StreamChat
import com.getstream.sdk.chat.enums.Filters.*
import com.getstream.sdk.chat.rest.User
import com.getstream.sdk.chat.viewmodel.ChannelListViewModel
import java.util.*

/**
 * This activity shows a list of channels
 */
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {

        super.onCreate(savedInstanceState)

        // setup the client using the example API key
        // normally you would call init in your Application class and not the activity
        StreamChat.init("b67pax5b2wdq", this.applicationContext)
        val client = StreamChat.getInstance(this.application)
        val extraData = HashMap<String, Any>()
        extraData["name"] = "Paranoid Android"
        extraData["image"] = "https://bit.ly/2TIt8NR"
        val currentUser = User("fragrant-mud-6", extraData)
        // User token is typically provided by your server when the user authenticates
        client.setUser(
            currentUser,
            "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiZnJhZ3JhbnQtbXVkLTYifQ.jSikUm1uyyGnKc1LiCOE8vwbGCx69PvgrA2ROa8XTls"
        )

        // we're using data binding in this example
        val binding: ActivityMainBinding =
            DataBindingUtil.setContentView(this, R.layout.activity_main)
        // Specify the current activity as the lifecycle owner.
        binding.lifecycleOwner = this

        // most the business logic for chat is handled in the ChannelListViewModel view model
        val viewModel = ViewModelProvider(this).get(ChannelListViewModel::class.java)

        binding.viewModel = viewModel
        binding.channelList.setViewModel(viewModel, this)

        // query all channels of type messaging
        val filter = and(eq("type", "messaging"), `in`("members", "fragrant-mud-6"))
        viewModel.setChannelFilter(filter)

        // click handlers for clicking a user avatar or channel
        binding.channelList.setOnChannelClickListener { channel ->
            // open the channel activity
        }
        binding.channelList.setOnUserClickListener { user ->
            // open your user profile
        }

    }
}

A working Channel List

Run your application and you should see a channel list interface as shown on the right. Let's have a quick look at the source code shown above. First, we start by creating a connection to Stream by initializing StreamChat. For a production app, we recommend configuring StreamChat in your Application class.

Next we call setUser on the client object. This authenticates the current user. Because this is a tutorial, we pre-generated a user token to keep things easy to follow. In a real-world application, your authentication backend would generate such token at log-in / signup and hand it over to the mobile app. More information about this is available on the Chat API docs.

Most of the logic for this activity is done in the viewModel. The viewModel uses the low-level client to keep all chat information in sync and exposes it to the activity and layout via LiveData. The other thing to note is the FilterObject. This demo shows all the channels of the "messaging" type. Stream supports advanced queries to retrieve a list of channels. The most common use case is querying the channels that a certain member is participating in. The documentation about querying channels covers this in more detail.

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 ChannelHeader, MessageList, and MessageInput custom views. 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"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="viewModel"
            type="com.getstream.sdk.chat.viewmodel.ChannelViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context="com.example.chattutorial.ChannelActivity">

        <com.getstream.sdk.chat.view.ChannelHeaderView
            android:id="@+id/channelHeader"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="#FFF"
            app:streamChannelHeaderBackButtonShow="true"
            app:layout_constraintEnd_toStartOf="@+id/messageList"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <com.getstream.sdk.chat.view.MessageListView
            android:id="@+id/messageList"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_marginBottom="10dp"
            android:background="#FFF"
            app:layout_constraintBottom_toTopOf="@+id/message_input"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/channelHeader"
            />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:padding="6dp"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <ProgressBar
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:isGone="@{!safeUnbox(viewModel.loading)}"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <ProgressBar
            android:layout_width="25dp"
            android:layout_height="25dp"
            app:isGone="@{!safeUnbox(viewModel.loadingMore)}"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="parent" />

        <com.getstream.sdk.chat.view.MessageInputView
            android:id="@+id/message_input"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="32dp"
            android:layout_marginBottom="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintStart_toEndOf="@+id/messageList" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

Note how the constraint layout contains a ChannelHeaderView, MessageListView, MessageInput and 2 loading indicators. As a next step we'll want to launch the ChannelActivity when you click a channel. To do this open MainActivity and make the following changes:


// Setup two strings for the intent
public class MainActivity extends AppCompatActivity {

+    public static final String EXTRA_CHANNEL_TYPE = "com.example.chattutorial.CHANNEL_TYPE";
+    public static final String EXTRA_CHANNEL_ID = "com.example.chattutorial.CHANNEL_ID";

    //....

    // replace the channel click handler
     binding.channelList.setOnChannelClickListener(channel -> {
            // open the channel activity
+            Intent intent = new Intent(MainActivity.this, ChannelActivity.class);
+            intent.putExtra(EXTRA_CHANNEL_TYPE, channel.getType());
+            intent.putExtra(EXTRA_CHANNEL_ID, channel.getId());
+            startActivity(intent);
     });

}

class MainActivity : AppCompatActivity() {

    //....

    // replace the channel click handler
        binding.channelList.setOnChannelClickListener { channel ->
            // open the channel activity
+            val intent = ChannelActivity.newIntent(this, channel)
+            startActivity(intent)
        }

    //....

}

Now all that's left to do is to configure the Channel Activity. Replace the source code in ChannelActivity with:

package com.example.chattutorial;

import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.text.TextUtils;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.databinding.DataBindingUtil;
import androidx.lifecycle.ViewModelProvider;
import androidx.lifecycle.ViewModelProviders;

import com.example.chattutorial.databinding.ActivityChannelBinding;
import com.getstream.sdk.chat.StreamChat;
import com.getstream.sdk.chat.model.Channel;
import com.getstream.sdk.chat.rest.core.Client;
import com.getstream.sdk.chat.utils.Constant;
import com.getstream.sdk.chat.utils.PermissionChecker;
import com.getstream.sdk.chat.view.MessageInputView;
import com.getstream.sdk.chat.viewmodel.ChannelViewModel;
import com.getstream.sdk.chat.viewmodel.ChannelViewModelFactory;

import androidx.lifecycle.MutableLiveData;
import com.getstream.sdk.chat.model.Event;
import com.getstream.sdk.chat.rest.core.ChatChannelEventHandler;
import java.util.ArrayList;
import java.util.List;

/**
 * Show the messages for a channel
 */
public class ChannelActivity extends AppCompatActivity
        implements MessageInputView.PermissionRequestListener {

    private ChannelViewModel viewModel;
    private ActivityChannelBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // receive the intent and create a channel object
        Intent intent = getIntent();
        String channelType = intent.getStringExtra(MainActivity.EXTRA_CHANNEL_TYPE);
        String channelID = intent.getStringExtra(MainActivity.EXTRA_CHANNEL_ID);
        Client client = StreamChat.getInstance(getApplication());

        // we're using data binding in this example
        binding = DataBindingUtil.setContentView(this, R.layout.activity_channel);
        // most the business logic of the chat is handled in the ChannelViewModel view model
        binding.setLifecycleOwner(this);

        Channel channel = client.channel(channelType, channelID);
        ChannelViewModelFactory viewModelFactory = new ChannelViewModelFactory(this.getApplication(), channel);
        viewModel = new ViewModelProvider(this, viewModelFactory).get(ChannelViewModel.class);

        // connect the view model
        binding.setViewModel(viewModel);
        binding.channelHeader.setViewModel(viewModel, this);

        binding.messageList.setViewModel(viewModel, this);
        binding.messageInput.setViewModel(viewModel, this);
    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        // If you are using own MessageInputView please comment this line.
        binding.messageInput.captureMedia(requestCode, resultCode, data);
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
                                           @NonNull int[] grantResults) {
        // If you are using own MessageInputView please comment this line.
        binding.messageInput.permissionResult(requestCode, permissions, grantResults);
    }

    @Override
    public void openPermissionRequest() {
        PermissionChecker.permissionCheck(this, null);
    }
}
package com.example.chattutorial

import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import com.example.chattutorial.databinding.ActivityChannelBinding
import com.getstream.sdk.chat.StreamChat
import com.getstream.sdk.chat.model.Channel
import com.getstream.sdk.chat.model.Event
import com.getstream.sdk.chat.rest.core.ChatChannelEventHandler
import com.getstream.sdk.chat.utils.PermissionChecker
import com.getstream.sdk.chat.view.MessageInputView.PermissionRequestListener
import com.getstream.sdk.chat.viewmodel.ChannelViewModel
import com.getstream.sdk.chat.viewmodel.ChannelViewModelFactory
import java.util.*


/**
 * Show the messages for a channel
 *
 */
class ChannelActivity : AppCompatActivity(), PermissionRequestListener {

    private lateinit var binding: ActivityChannelBinding


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

        // receive the intent and create a channel object
        val intent = intent
        val channelType = intent.getStringExtra(EXTRA_CHANNEL_TYPE)
        val channelID = intent.getStringExtra(EXTRA_CHANNEL_ID)
        val client = StreamChat.getInstance(application)

        // we're using data binding in this example
        binding = DataBindingUtil.setContentView(this, R.layout.activity_channel)
        // most the business logic of the chat is handled in the ChannelViewModel view model
        binding.lifecycleOwner = this

        val channel = client.channel(channelType, channelID)
        val viewModelFactory = ChannelViewModelFactory(application, channel)
        val viewModel = ViewModelProvider(this, viewModelFactory).get(ChannelViewModel::class.java)

        // connect the view model
        binding.viewModel = viewModel
        binding.messageList.setViewModel(viewModel, this)
        binding.messageInput.setViewModel(viewModel, this)
        // TODO: add typing events
        binding.channelHeader.setViewModel(viewModel, this)
        // If you are using own MessageInputView please comment this line.
        binding.messageInput.setPermissionRequestListener(this)

    }

    override fun onActivityResult(
        requestCode: Int,
        resultCode: Int,
        data: Intent?
    ) {
        super.onActivityResult(requestCode, resultCode, data)
        // If you are using own MessageInputView please comment this line.
        binding.messageInput.captureMedia(requestCode, resultCode, data)
    }

    override fun onRequestPermissionsResult(
        requestCode: Int, permissions: Array<String?>,
        grantResults: IntArray
    ) { // If you are using own MessageInputView please comment this line.
        binding.messageInput.permissionResult(requestCode, permissions, grantResults)
    }

    override fun openPermissionRequest() {
        PermissionChecker.permissionCheck(this, null)
        // If you are writing a Channel Screen in a Fragment, use the code below instead of the code above.
        // PermissionChecker.permissionCheck(getActivity(), this);
    }


    companion object {

        private const val EXTRA_CHANNEL_TYPE = "com.example.chattutorial.CHANNEL_TYPE"
        private const val EXTRA_CHANNEL_ID = "com.example.chattutorial.CHANNEL_ID"

        fun newIntent(context: Context, channel: Channel): Intent {
            val intent = Intent(context, ChannelActivity::class.java)
            intent.putExtra(EXTRA_CHANNEL_TYPE, channel.type)
            intent.putExtra(EXTRA_CHANNEL_ID, channel.id)
            return intent
        }
    }

}

There are a few things to talk about here. Similar to the MainActivity most of the logic is handled by the viewModel. You'll see how we pass the viewModel to the various components. If you hit run and click a channel you should see a chat interface as shown on the right.

Chat Features

This is a good moment to talk about features. Did you already try posting a link? Stream Chat provides you with all the features you need to build an engaging messaging experience:

  1. Link previews will be generated automatically when you send a link
  2. Commands: focus on the composer and type / to use commands, like /giphy
  3. Reactions: tap on a message to add reactions or long-press on images
  4. Attach images: use the plus button ⨁ in the composer to attach images or files
  5. Edit message: long-press on your messages to open the menu with the Edit button
  6. Typing events: you’ll see typing events at the bottom of messages when someone starts typing
  7. Threads: you can create threads to any message in a channel, just tap on a message and hit Reply to enter the thread view.

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 5 minutes of work. There are three ways to customize your chat experience:

  1. Style the MessageListView using attributes (easy)
  2. Write a view holder to render custom message or attachment types (easy)
  3. Build a view using Stream's low-level Java SDK (quite a bit of work)

In the next sections, we'll show an example for each type of customization. We'll start with changing the colors of the chat messages to match your theme.

Open activity_channel.xml and replace the MessageListView with the following code for an epic green message style:

<com.getstream.sdk.chat.view.MessageListView
    android:id="@+id/messageList"
    android:layout_width="match_parent"
    android:layout_height="0dp"
    android:layout_marginBottom="10dp"
    app:layout_constraintBottom_toTopOf="@+id/message_input"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/channelHeader"

    android:background="#f3f5f8"
    app:streamMessageTextColorMine="#FFF"
    app:streamMessageBackgroundColorMine="#70af74"
    app:streamMessageTextColorTheirs="#000"
    app:streamMessageBackgroundColorTheirs="#FFF"
    />

Next, update your app and write a new message. Your chat should now look similar to the image shown on the right. Note how messages that you wrote now show up with a green background and white text.

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 an 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 we'll create a new Java or Kotlin file called MyMessageViewHolderFactory and enter the following code:

package com.example.chattutorial;

import android.view.ViewGroup;

import com.getstream.sdk.chat.adapter.AttachmentListItemAdapter;
import com.getstream.sdk.chat.adapter.BaseAttachmentViewHolder;
import com.getstream.sdk.chat.adapter.MessageViewHolderFactory;
import com.getstream.sdk.chat.model.Attachment;
import com.getstream.sdk.chat.rest.Message;

import java.util.List;

public class MyMessageViewHolderFactory extends MessageViewHolderFactory {
    private static int IMGUR_TYPE = 0;

    @Override
    public int getAttachmentViewType(Message message, Boolean mine, Position position, List<Attachment> attachments, Attachment attachment) {
        if (attachment.getImageURL() != null && attachment.getImageURL().contains("imgur")) {
            return IMGUR_TYPE;
        }
        return super.getAttachmentViewType(message, mine, position, attachments, attachment);
    }

    @Override
    public BaseAttachmentViewHolder createAttachmentViewHolder(AttachmentListItemAdapter adapter, ViewGroup parent, int viewType) {
        BaseAttachmentViewHolder holder;
        if (viewType == IMGUR_TYPE) {
            holder = new AttachmentViewHolderImgur(R.layout.list_item_attach_imgur, parent);
        } else {
            holder = super.createAttachmentViewHolder(adapter, parent, viewType);
        }

        return holder;
    }
}
package com.example.chattutorial

import android.view.ViewGroup

import com.getstream.sdk.chat.adapter.AttachmentListItemAdapter
import com.getstream.sdk.chat.adapter.BaseAttachmentViewHolder
import com.getstream.sdk.chat.adapter.MessageViewHolderFactory
import com.getstream.sdk.chat.model.Attachment
import com.getstream.sdk.chat.rest.Message

class MyMessageViewHolderFactory : MessageViewHolderFactory() {

    override fun getAttachmentViewType(
        message: Message?,
        mine: Boolean?,
        position: Position?,
        attachments: List<Attachment>?,
        attachment: Attachment
    ): Int {
        return if (attachment.imageURL != null && attachment.imageURL.indexOf("imgur") != -1) {
            IMGUR_TYPE
        } else super.getAttachmentViewType(message, mine, position, attachments, attachment)
    }

    override fun createAttachmentViewHolder(
        adapter: AttachmentListItemAdapter,
        parent: ViewGroup,
        viewType: Int
    ): BaseAttachmentViewHolder {
        val holder: BaseAttachmentViewHolder
        if (viewType == IMGUR_TYPE) {
            holder = AttachmentViewHolderImgur(R.layout.list_item_attach_imgur, parent)
        } else {
            holder = super.createAttachmentViewHolder(adapter, parent, viewType)
        }

        return holder
    }

    companion object {
        private const val IMGUR_TYPE = 0
    }
}

As a next step we'll hookup your new Message View Holder factory to the message list component. Open ChannelActivity and add this line just below the // connect the view model line:

   // connect the view model
+   binding.messageList.setViewHolderFactory(new MyMessageViewHolderFactory());
   // connect the view model
+   val factory = MyMessageViewHolderFactory()
+   binding.messageList.setViewHolderFactory(factory)

The MessageList now knows about your custom view holder factory. We now need to implement the AttachmentViewHolderImgur class. Create a new file called AttachmentViewHolderImgur and add this code:

package com.example.chattutorial;

import android.content.Context;
import android.graphics.drawable.Drawable;
import android.view.ViewGroup;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.getstream.sdk.chat.adapter.BaseAttachmentViewHolder;
import com.getstream.sdk.chat.adapter.MessageListItem;
import com.getstream.sdk.chat.model.Attachment;
import com.getstream.sdk.chat.rest.Message;
import com.getstream.sdk.chat.utils.roundedImageView.PorterShapeImageView;
import com.getstream.sdk.chat.view.MessageListView;
import com.bumptech.glide.Glide;
import com.getstream.sdk.chat.view.MessageListViewStyle;

public class AttachmentViewHolderImgur extends BaseAttachmentViewHolder {
    private PorterShapeImageView iv_media_thumb;

    AttachmentViewHolderImgur(int resId, ViewGroup parent) {
        super(resId, parent);

        iv_media_thumb = itemView.findViewById(R.id.iv_media_thumb);
    }

    @Override
    public void bind(@NonNull Context context, @NonNull MessageListItem messageListItem, @NonNull Message message, @NonNull Attachment attachment, @NonNull MessageListViewStyle style, @NonNull MessageListView.BubbleHelper bubbleHelper, @Nullable MessageListView.AttachmentClickListener clickListener, @Nullable MessageListView.MessageLongClickListener longClickListener) {
        Drawable background = bubbleHelper.getDrawableForAttachment(messageListItem.getMessage(), messageListItem.isMine(), messageListItem.getPositions(), attachment);
        iv_media_thumb.setShape(context, background);

        Glide.with(context)
                .load(attachment.getThumbURL())
                .into(iv_media_thumb);
    }

}
package com.example.chattutorial


import com.getstream.sdk.chat.rest.Message


import android.content.Context
import android.view.ViewGroup
import com.bumptech.glide.Glide
import com.getstream.sdk.chat.adapter.BaseAttachmentViewHolder
import com.getstream.sdk.chat.adapter.MessageListItem
import com.getstream.sdk.chat.model.Attachment
import com.getstream.sdk.chat.utils.roundedImageView.PorterShapeImageView
import com.getstream.sdk.chat.view.MessageListView
import com.getstream.sdk.chat.view.MessageListViewStyle


class AttachmentViewHolderImgur(resId: Int, parent: ViewGroup) :
    BaseAttachmentViewHolder(resId, parent) {
    private val ivMediaThumb: PorterShapeImageView = itemView.findViewById(R.id.iv_media_thumb)

    override fun bind(
        context: Context,
        messageListItem: MessageListItem,
        message: Message,
        attachment: Attachment,
        style: MessageListViewStyle,
        bubbleHelper: MessageListView.BubbleHelper,
        clickListener: MessageListView.AttachmentClickListener?,
        longClickListener: MessageListView.MessageLongClickListener?
    ) {
        val background = bubbleHelper.getDrawableForAttachment(
            messageListItem.message,
            messageListItem.isMine,
            messageListItem.positions,
            attachment
        )
        ivMediaThumb.setShape(context, background)

        Glide.with(context)
            .load(attachment.thumbURL)
            .into(ivMediaThumb)
    }
}

Next download the Imgur logo and add it to your drawable folder.


As a final step create a XML Layout called list_item_attach_imgur (File > New > XML > Layout XML File):

<?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="1px"
        android:layout_marginTop="1px"
        android:layout_marginEnd="1px"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tv_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>

Your custom view holder

Run your app and 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/CiZeA

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. Note you can typically achieve what you want by using a custom attachment type. Most of the time you don't need a custom message type (which is more complex to implement).

Imgur Logo overlay on the chat interface

Creating your own Channel Header component

The low-level Java SDK client enables you to talk directly to Stream's API. This gives you the flexibility to implement any messaging/chat UI that you want. As an example, we'll build a channel header UI element that shows who is typing.

First off, open activity_channel.xml and add a typing variable. Note that you need to add the typing variable in the data section of your layout:

<data>

    <variable
        name="viewModel"
        type="com.getstream.sdk.chat.viewmodel.ChannelViewModel" />

+    <variable
+        name="typing"
+        type="String" />
</data>

Next in activity_channel.xml replace the com.getstream.sdk.chat.view.ChannelHeaderView with a TextView:

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

+   <TextView
+    android:id="@+id/channelHeader"
+    android:layout_width="match_parent"
+    android:layout_height="31dp"
+    android:background="#CCC"
+    android:text="@{typing}"
+    android:gravity="center"
+    android:layout_marginBottom="5dp"
+    app:layout_constraintEnd_toStartOf="@+id/messageList"
+    app:layout_constraintStart_toStartOf="parent"
+    app:layout_constraintTop_toTopOf="parent" />

Now open ChannelActivity and attach an event handler to the channel. The complete documentation for the Java client is available in the docs. We use the event handler to populate a LiveData object. Next, we observe the LiveData object and write to the layout's typing variable. Remove the channelHeader.setViewModel call and make the following changes to the code:

// new imports
+   import androidx.lifecycle.MutableLiveData;
+   import com.getstream.sdk.chat.model.Event;
+   import com.getstream.sdk.chat.rest.core.ChatChannelEventHandler;
+   import java.util.ArrayList;
+   import java.util.List;

// ...
// inside onCreate

-    binding.channelHeader.setViewModel(viewModel, this);

+    MutableLiveData<List<String>> currentlyTyping = new MutableLiveData<>(new ArrayList<String>());
+    channel.addEventHandler(new ChatChannelEventHandler() {
+        @Override
+        public void onTypingStart(Event event) {
+            List<String> typingCopy = currentlyTyping.getValue();
+            if (!typingCopy.contains(event.getUser().getName())) {
+                typingCopy.add(event.getUser().getName());
+            }
+            currentlyTyping.postValue(typingCopy);
+        }
+
+        @Override
+        public void onTypingStop(Event event) {
+            List<String> typingCopy = currentlyTyping.getValue();
+            typingCopy.remove(event.getUser().getName());
+            currentlyTyping.postValue(typingCopy);
+        }
+    });
+    currentlyTyping.observe(this, users -> {
+        String typing = "nobody is typing";
+        if (!users.isEmpty()) {
+            typing = "typing: " + String.join(", ", users);
+        }
+        binding.setTyping(typing);
+    });
// new imports
+   import androidx.lifecycle.MutableLiveData
+   import com.getstream.sdk.chat.model.Event
+   import com.getstream.sdk.chat.rest.core.ChatChannelEventHandler
+   import java.util.ArrayList
+   import androidx.lifecycle.Observer

// ...
// inside onCreate
  
-    // TODO: add typing events
-    binding.channelHeader.setViewModel(viewModel, this)
+    val currentlyTyping = MutableLiveData<List<String>>(ArrayList())
+    channel.addEventHandler(object : ChatChannelEventHandler() {
+        override fun onTypingStart(event: Event) {
+            val typing = currentlyTyping.value ?: listOf()
+            val typingCopy : MutableList<String> = typing.toMutableList()
+            if (typingCopy.contains(event.user.name).not()) {
+                typingCopy.add(event.user.name)
+            }
+            currentlyTyping.postValue(typingCopy)
+        }

+        override fun onTypingStop(event: Event) {
+            val typing = currentlyTyping.value ?: listOf()
+            val typingCopy : MutableList<String> = typing.toMutableList()
+            typingCopy.remove(event.user.name)
+            currentlyTyping.postValue(typingCopy)
+        }
+    })

+    val typingObserver = Observer<List<String>> { users ->
+        var typing = "nobody is typing"
+        if (users.isNotEmpty()) {
+            typing = "typing: " + users.joinToString(", ")
+        }
+        binding.typing = typing
+    }
+    currentlyTyping.observe(this,typingObserver)
  
// ...

Your custom channel view

When you run your chat app you should now see a little typing indicator above the channel. Note that the ChannelViewModel also exposes the getTypingUsers method which returns a LiveData object. The above example was a little complex, but it gives you an idea of how you can use the lower-level API.

Imgur Logo overlay on the chat interface

Final thoughts

In this Android Chat App tutorial, we 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.

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 500 million end-users, so you can feel confident about our APIs.

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.