Communicating with an AI chatbot can be a frustrating experience. However, with advances in machine learning and artificial intelligence, there is no excuse for not providing a positive experience for chat users. AI assistant advances allow machines and computers to process, understand, and reply to human messages in a genuinely useful way to the human contacting your system.
A few of the current applications of chatbots are:
- Customer support systems
- Knowledgebase and/or help centers
- Appointment scheduling
- Booking events, hotels, and/or restaurant reservations
In this tutorial, I will be describing how to build an Android app with a chatbot using Stream’s Chat API and Dialogflow. We'll also walk through how to implement custom event handlers to allow for real-time communication; when a message is sent, our event listener will send the message to the backend, and the backend server will send the text to Dialogflow, which will process the message and reply with the most appropriate answer.
Once this flow has been completed, the backend server will send the message to the "Chat" channel in your application. Through this process, your Android app will be updated with both the text from the user and the message updated from the backend!
By the end of this post, you should have an app that looks a lot like this:
As always, the entire source code of this tutorial can be found on GitHub.
Note: Because this app is created using Android Studio, the directory structure, when viewed inside of a folder, may be different than you would typically expect.
Prerequisites
To build this application, we'll be utilizing the following tech:
Note: Please ensure you've installed all of the above before proceeding. You won't need to worry about actually creating a Dialogflow account just yet, as we'll review the details in a few sections.
Setup the App’s Structure
This tutorial will contain two applications: the backend server and the Android app. The first step is to create a directory to contain both of these; this can be done with the following commands:
12mkdir chatbot-android cd chatbot-android && server
This will create a chatbot-android
directory and, within that, a server
directory.
Configure the App with Android Studio
Open the Android Studio app and create a new application at chatbot-android/DialogFlowApplication
(so our chatbot-android
directory will now contain a DialogFlowApplication
directory, as well as a server
directory). You'll also want to set the Android version to "API 29" and select "Java" as the programming language in the Android Studio settings for this app.
Configure Dialogflow
Head to Dialogflow’s dashboard to set up your new chatbot! Create a free account (or sign in if you already have an account) and open your Dialogflow dashboard. If your account is new, you must connect Dialogflow to a Google Cloud project. It is a relatively simple process; you must follow the on-screen instructions.
A guide is also available in the Google Cloud docs.
Once Dialogflow is connected to Google Cloud, the next step will be creating an "intent."
You'll need to start by creating an agent by clicking the CREATE button at the top right (once the agent has been successfully created, you'll be prompted to create your intent, which will be where you can begin setting up how your bot will respond to your users):
Next, navigate to Small Talk in the left panel to begin setting up your Small Talk agent. The "Small Talk agent" is what is responsible for processing the user’s text and finding an appropriate response:
Build the Backend Server
Our server will accomplish a few tasks:
- Generate authentication token for users
- Connect the app to Dialogflow and send messages to the chat channel
Navigate to the server
directory we created earlier (cd server
) to:
- Start a new project
- Install a few packages
- Build the actual app(!)
You can use the following commands to start a new project and install the required packages:
12yarn init -y yarn add dialogflow uuid express stream-chat cors dotenv
The next step is to create an index.js
file, which will contain the logic of our entire server:
touch index.js
Now, we'll copy and paste the following code into the newly created index.js
file:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107const dialogflow = require("dialogflow"); const uuid = require("uuid"); const express = require("express"); const StreamChat = require("stream-chat").StreamChat; const cors = require("cors"); const dotenv = require("dotenv"); const port = process.env.PORT || 5200; async function runSample(text, projectId = process.env.GOOGLE_PROJECT_ID) { const sessionId = uuid.v4(); const sessionClient = new dialogflow.SessionsClient(); const sessionPath = sessionClient.sessionPath(projectId, sessionId); const request = { session: sessionPath, queryInput: { text: { text: text, languageCode: "en-US", }, }, }; const responses = await sessionClient.detectIntent(request); const result = responses[0].queryResult; if (result.action === "input.unknown") { // If unknown, return the original text return text; } return result.fulfillmentText; } dotenv.config(); const app = express(); app.use(express.json()); app.use(cors()); const client = new StreamChat(process.env.API_KEY, process.env.API_SECRET); const channel = client.channel("messaging", "dialogflow", { name: "Dialogflow chat", created_by: { id: "admin" }, }); app.post("/dialogflow", async (req, res) => { const { text } = req.body; if (text === undefined || text.length == 0) { res.status(400).send({ status: false, message: "Text is required", }); return; } runSample(text) .then((text) => { channel.sendMessage({ text: text, user: { id: "admin", image: "https://bit.ly/2TIt8NR", name: "Admin bot", }, }); res.json({ status: true, text: text, }); }) .catch((err) => { console.log(err); res.json({ status: false, }); }); }); app.post("/auth", async (req, res) => { const username = req.body.username; const token = client.createToken(username); await client.updateUser({ id: username, name: username }, token); await channel.create(); await channel.addMembers([username, "admin"]); await channel.sendMessage({ text: "Welcome to this channel. Ask me few questions", user: { id: "admin" }, }); res.json({ status: true, token, username, }); }); app.listen(port, () => console.log(`App listening on port ${port}!`));
In the above code, we create two endpoints, using app.post
:
/dialogflow
: used to connect the application with Dialogflow, get the result, and send it in to the chat channel, so that the Android app user gets updated in real-time/auth
: used to create the user’s token, which will be used to identify and authenticate the user
Finally, create a .env
file in the server
directory, using the following command:
touch .env
Once the above command succeeds, you will need to fill it with the appropriate values:
123API_KEY=your_API_KEY API_SECRET=your_API_SECRET GOOGLE_PROJECT_ID=your_GOOGLE_CLOUD_PROJECT_ID
Please update these values with the correct ones retrieved from the GetStream dashboard and Google Cloud. The Google Cloud project ID should be the same one you enabled the Dialogflow API on previously.
The backend server can be started now by running:
node index.js
Build the Android App
The first step is to define our dependencies in the Gradle build file; copy and paste the following code into your build.gradle
file:
1234567891011121314151617181920212223242526272829​​buildscript { repositories { google() jcenter() } dependencies { classpath 'com.android.tools.build:gradle:3.6.1' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } } allprojects { repositories { google() jcenter() // Add this line maven { url "https://jitpack.io" } } } task clean(type: Delete) { delete rootProject.buildDir }
The next step is to populate the application level build.gradle
file (at app/build.gradle
)0 with the following:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051apply plugin: 'com.android.application' android { compileSdkVersion 29 buildToolsVersion "29.0.3" defaultConfig { applicationId "com.example.dialogflowapplication" minSdkVersion 28 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.2' implementation 'com.github.bumptech.glide:glide:4.11.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0' implementation 'androidx.recyclerview:recyclerview:1.1.0' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' implementation("com.squareup.okhttp3:okhttp:4.4.1") implementation 'com.google.code.gson:gson:2.8.6' implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'androidx.appcompat:appcompat:1.1.0' implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta4' testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test.ext:junit:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' }
Since these edits are done in Android studio, a Sync now button should pop up; click it when it appears.
MainActivity.java File
Once these dependencies have been installed, we’ll focus on building the actual application. We'll start with MainActivity.java
, as it is the entry point of the app:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849<?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.dialogflowapplication.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>
While the above code is complete, you will notice the MainActivity
contains a few place holders. You will need to send a request to the backend server using the /auth
endpoint to get these values. You can use any HTTP request tool, but we chose to use a cURL request:
curl -d '{"username" : "username"}' -H "Content-Type: application/json" -X POST http://localhost:5200/auth
The above command returns a response that contains the username
and a JSON Web Token (JWT). The values to be replaced are as follows:
your_API_KEY
with the API Key from Stream dashboard<your_USER_ID>
with theusername
<your Full Name>
with your full nameyour_AUTH_TOKEN_FROM_BACKEND_SERVER
with the JWT
To complete our work with the MainActivity.java
functionality, we'll update the associated layout file at res/layout/activity_main.xml
:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465package com.example.dialogflowapplication; import android.content.Intent; import android.os.Bundle; import androidx.appcompat.app.AppCompatActivity; import androidx.databinding.DataBindingUtil; import androidx.lifecycle.ViewModelProviders; import com.example.dialogflowapplication.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.eq; public class MainActivity extends AppCompatActivity { public static final String EXTRA_CHANNEL_TYPE = "com.example.dialogflowapplication.CHANNEL_TYPE"; public static final String EXTRA_CHANNEL_ID = "com.example.dialogflowapplication.CHANNEL_ID"; @Override protected void onCreate(Bundle savedInstanceState) { 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("your_API_KEY", this.getApplicationContext()); Client client = StreamChat.getInstance(this.getApplication()); HashMap<String, Object> extraData = new HashMap<>(); extraData.put("name", "<your Full Name>"); extraData.put("image", "https://bit.ly/2TIt8NR"); User currentUser = new User("<your_USER_ID>", extraData); // User token is typically provided by your server when the user authenticates client.setUser(currentUser,"your_AUTH_TOKEN_FROM_BACKEND_SERVER"); // 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 = eq("type", "messaging"); viewModel.setChannelFilter(filter); // click handlers for clicking a user avatar or channel binding.channelList.setOnChannelClickListener(channel -> { Intent intent = new Intent(MainActivity.this, ChannelActivity.class); intent.putExtra(EXTRA_CHANNEL_TYPE, channel.getType()); intent.putExtra(EXTRA_CHANNEL_ID, channel.getId()); startActivity(intent); }); binding.channelList.setOnUserClickListener(user -> { // open your user profile }); } }
ChannelActivity.java
Next, you will need to create a new _Empty Activity_
called ChannelActivity
. Once that is created, populate your ChannelActivity.java
file with the following code:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172package com.example.dialogflowapplication; import androidx.appcompat.app.AppCompatActivity; import android.os.Bundle; import android.content.Intent; import androidx.annotation.NonNull; import androidx.databinding.DataBindingUtil; import androidx.lifecycle.ViewModelProvider; import com.example.dialogflowapplication.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.PermissionChecker; import com.getstream.sdk.chat.view.MessageInputView; import com.getstream.sdk.chat.viewmodel.ChannelViewModel; import com.getstream.sdk.chat.viewmodel.ChannelViewModelFactory; /** * 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); } }
Then, fill out your layout/activity_channel.xml
file, like so:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677<?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.dialogflowapplication.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.example.dialogflowapplication.DialogInputView 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>
DialogInputView.java
You will notice that in activity_channel.xml
, we make use of a class called com.example.dialogflowapplication.DialogInputView
. DialogInputView.java
is non-existent, so it will need to be created. This doesn’t need to be an activity; a plain Java class will suffice. After creating this file, add the following:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364package com.example.dialogflowapplication; import android.content.Context; import android.util.AttributeSet; import android.util.Log; import android.widget.Toast; import com.getstream.sdk.chat.interfaces.MessageSendListener; import com.getstream.sdk.chat.rest.Message; import com.getstream.sdk.chat.view.MessageInputView; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; public class DialogInputView extends MessageInputView implements MessageSendListener { private final static String TAG = DialogInputView.class.getSimpleName(); public DialogInputView(Context context, AttributeSet attrs) { super(context, attrs); setMessageSendListener(this); } @Override public void onSendMessageSuccess(Message message) { Log.d(TAG, "Sent message! :" + message.getText()); Thread thread = new Thread(new Runnable(){ @Override public void run() { try { String json = String.format("{\"text\" : \"%s\"}", message.getText()); Log.d(TAG, String.format("Sending JSON to server... %s", json)); try { OkHttpClient client = new OkHttpClient(); RequestBody body = RequestBody.create(json, MediaType.get("application/json; charset=utf-8")); Request request = new Request.Builder() .url("https://subdomain.ngrok.io/dialogflow") .post(body) .build(); try (Response response = client.newCall(request).execute()) { String res = "" + response.body().string(); Log.d("OOPS", res); } } catch (Exception e) { Log.e(TAG, "" + e.getMessage()); Toast.makeText(getContext(), "" + e.getMessage(), Toast.LENGTH_SHORT).show(); onSendMessageError("" + e.getMessage()); } } catch (Exception e) { Log.e(TAG, "" + e.getMessage()); } } }); thread.start(); } @Override public void onSendMessageError(String errMsg) { Log.d(TAG, "Failed send message! :" + errMsg); } }
In the above code, we implement the MessageSendListener
. This interface allows us to create callbacks for messages, whether successful or not. In the above code, we also define the onSendMessageSuccess
method, which handles the callback whenever the user successfully sends a message.
Notice the generic request URL in the onSendMessageSuccess
method: https://subdomain.ngrok.io/dialogflow
. You'll need to replace this URL with your ngrok URL, which you can find by running the following command:
ngrok http 5200
Now, our application is complete, and you can click the Play button to run the app on a simulator:
Wrap-Up
In this tutorial, I have described how to build a chatbot with Dialogflow and Stream’s chat components for Android. I have also shown how to implement callbacks to determine whether or not messages are successful. We would love to see how you implement these strategies in your app; please feel free to show off your creations to us!. To learn more, visit the Android SDK and Compose chat tutorial pages.
Thanks for reading, and happy coding!