This tutorial will take you through building a realtime chat app for Android. We'll be implementing quite a few features and showing you just how easy it is to do this with deepstreamHub. After this, we'll be able to:
-view a list of users and whether they are online or offline
-chat with users on a one-to-one basis
-be notified when the person you're chatting to is typing
-edit your old messages and have them synced on other devices
This tutorial will be covering a lot of concepts in deepstreamHub and we'd definitely recommend being familiar with Records and Lists before giving this a go, prior Android or Java experience will be helpful as well.
Because Java and Android projects have a lot of boilerplate associated with them, for the sake of brevity we'll be cutting the cruft and including the most important parts for this tutorial. If you have any questions please take a look at the GitHub repository or get in touch.
Create a free account and get your API key
Connect to deepstreamHub and log in
The first thing we'll do is create a new Android application with a LoginActivity
template, you can find more information on this here. We can then include the Java client sdk in our build.gradle
file as follows.
compile 'io.deepstream:deepstream.io-client-java:2.0.4'
With the Java sdk there are a few different ways of instantiating the client, but because the same client will need to be passed between activities, we'll be using the DeepstreamFactory
. To instantiate a client with this it's as simple as:
DeepstreamFactory deepstreamFactory = DeepstreamFactory.getInstance();
DeepstreamClient client = deepstreamFactory.getClient("<Your app url>");
Using the UserLoginTask
already included in the LoginActivity
, we can try to login a user with the details they provide. If they don't exist, we can use the deepstreamHub HTTP API to create them.
LoginResult result;
DeepstreamClient client;
try {
client = deepstreamFactory.getClient("<Your app url>");
} catch (URISyntaxException e) {
return false;
}
result = client.login(credentials);
if (!result.loggedIn()) {
// either incorrect credentials entered
if (result.getErrorEvent() == Event.INVALID_AUTH_DATA) {
Toast.makeText(getApplicationContext(), "Incorrect login details", Toast.LENGTH_LONG).show();
return false;
}
// or the user doesn't exist so we create them and log them in
createUser(credentials);
result = client.login(credentials);
if (!result.loggedIn()) {
Toast.makeTest(getApplicationContext(), "Error creating user", Toast.LENGTH_LONG).show();
return false;
}
}
The createUser
method is very simple and just creates an HTTP POST to our deepstreamHub API. You can find out more about our HTTP API here
private void createUser(JsonObject credentials) {
URL url = null;
HttpURLConnection conn = null;
BufferedWriter writer;
try {
String endpoint = "https://api.deepstreamhub/api/v1/user-auth/signup/" + "<Your api key>"
url = new URL(endpoint);
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json");
conn.setDoOutput(true);
conn.setDoInput(true);
writer = new BufferedWriter(new OutputStreamWriter(conn.getOutputStream()));
writer.write(credentials.toString());
writer.flush();
} catch (Exception e) {
e.printStackTrace();
}
}
When an authenticated user logs into into deepstreamHub, a client data object is returned containing the ID of the user as well as any other data specified while creating them. To access this client data from the LoginResult
we can call the getData()
method.
After this, we want to add the newly created user into our List
of users and add the users email and id to our StateRegistry
. The StateRegistry
is just a static class that holds some state between Android activities. Similarly the User
class is just a simple POJO containing the users ID, email and whether or not they are online.
Gson gson = new Gson();
JsonObject clientData = (JsonObject) gson.toJsonTree(result.getData());
String userId = clientData.get("id").getAsString();
String email = credentials.get("email").getAsString();
stateRegistry.setUserId(userId);
stateRegistry.setEmail(email);
User user = new User(userId, email, true);
Record record = client.record.getRecord("users/" + userId);
record.set(gson.toJsonTree(user));
// if this is the first time the user has logged in, they won't be in
// the list of users, so we need to add them
List users = client.record.getList("users");
if ( !Arrays.asList(users.getEntries()).contains(userId) ) {
users.addEntry(userId);
}
return true;
In the onPostExecute
method of our AsyncTask, we now just need to create an Intent
for the ChatOverView
activity:
@Override
protected void onPostExecute(final Boolean success) {
if (success) {
Intent intent = new Intent(ctx, ChatOverviewActivity.class);
startActivity(intent);
} else {
mPasswordView.setError("An error occurred connecting to deepstreamHub");
mPasswordView.requestFocus();
}
}
Viewing the users in our app
At this stage we have a deepstream List
called users
that contains the user ids of all the users in our application. From here, we need to:
-display all users in our application in a ListView
-start a chat with them whenever we click on a user
-have the list update in realtime whenever someone new joins
-display each users online/offline status and have it update whenever they login or logout
The first thing we need to do is get the list of user ids in our application. We can do this through the getList(String listname)
method, which will return all the Record
names in the list.
final List userIds = client.record.getList("users");
To populate the ListView
with these users, we'll be using a LinkedHashMap
with a custom adapter. By using a LinkedHashMap
, it'll be much easier for us to toggle the users online/offline state using presence. However we also need to remove the users own ID from this ArrayList
, it wouldn't make sense for a user to chat to themselves.
final String[] userIds = userList.getEntries();
users = new LinkedHashMap<>();
for (String userId : userIds) {
if (!userId.equals(stateRegistry.getUserId())) {
addUser(userId);
}
}
Our addUser
method looks as follows, all we're doing is getting each users metadata from their Record
, and adding it to our LinkedHashMap
.
Record userRecord = client.record.getRecord("users/" + id);
String email = userRecord.get("email").getAsString();
boolean online = userRecord.get("online").getAsBoolean();
users.put(id, new User(
id,
email,
online)
);
userRecord.discard();
Now it's a simple matter of creating an Adapter
for the list and setting the Adapter
on the ListView
.
final UserAdapter adapter = new UserAdapter(this, users);
ListView listView = (ListView) findViewById(R.id.user_list);
listView.setAdapter(adapter);
Next, to start a chat with a user, it's as simple as setting an OnItemClickListener
on the list and creating an Intent
with the ID and email of the user you want to.
listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
Intent i = new Intent(ctx, ChatActivity.class);
java.util.List<String> idList = new ArrayList<String>(users.keySet());
String userId = idList.get(position);
i.putExtra("userId", userId);
i.putExtra("userEmail", user.getEmail());
startActivity(i);
}
});
To update the list of users in real time, we just need to subscribe
to our deepstream List
and add a User
entry to the Adapter
whenever an entry is added.
userList.subscribe(new ListEntryChangedListener() {
@Override
public void onEntryAdded(String listName, final String userId, final int position) {
runOnUiThread(new Runnable() {
@Override
public void run() {
addUser(userId);
adapter.notifyDataSetChanged();
}
});
}
});
We'll also want the online/offline status of users to change whenever they login or logout. This is as simple as using the deepstream presence API and updating our list of users accordingly.
client.presence.subscribe(new PresenceEventListener() {
@Override
public void onClientLogin(String userId) {
User user = users.get(userId);
// happens first time a user connects
if (user == null) {
return;
}
user.setOnline(true);
runOnUiThread(new Runnable() {
@Override
public void run() {
adapter.notifyDataSetChanged();
}
});
}
@Override
public void onClientLogout(String userId) {
User user = users.get(userId);
user.setOnline(false);
runOnUiThread(new Runnable() {
@Override
public void run() {
adapter.notifyDataSetChanged();
}
});
}
});
At this stage of the tutorial, we should have something like this:
Sending and editing messages
The final stage of our chat application is being able to actually send messages between users and for this there are a few requirements, we want to:
-see when a user we're talking to is typing
-be able to edit older messages
-receive the messages in realtime
In deepstream, Records
are tiny blobs of JSON that we can modify, subscribe to and permission. This makes them perfect to model various things and we'll use them to represent our messages as follows:
{
"content": "...",
"email": "...",
"id": "...",
"msgId": "..."
}
We'll also need a Record
that represents the state of the conversation, we'll just be using this to show whether a user is typing or not, but it could also be used to store additional metadata about the conversation.
{
"${userOneId}": {
"isTyping": true
},
"${userTwoId}": {
"isTyping": false
}
}
When the ChatActivity
is started, either the user has talked to the user they clicked on before, or they haven't. If they haven't, we need to initialise the conversation. We do this by creating a List
with the name ${userId}::${otherUserId}
and adding Records
(the messages in our chat) to this List
. To ensure the ordering of the user Id's in the chat name, we can sort them in place and create the chat name from them as follows:
String[] tempChatArray = new String[]{ currentUserId, otherUserId };
Arrays.sort(tempChatArray);
chatName = tempChatArray[0] + "::" + tempChatArray[1];
chatList = client.record.getList(chatName);
if (chatList.isEmpty()) {
initialiseStateRecord(chatName);
}
Our initialiseStateRecord
method just creates our state Record
to hold various details about the conversation.
private void initialiseStateRecord(String chatName) {
stateRecord = client.record.getRecord(chatName + "/state");
JsonObject userMetaData = new JsonObject();
userMetaData.addProperty("isTyping", false);
stateRecord.set(currentUserId, userMetaData);
stateRecord.set(otherUserId, userMetaData);
}
After we've initialised the chat (or maybe it already has messages in it), we can put the messages from the List
into our ListView
. This is very similar to how we did it earlier in our ChatOverviewActivity
, except this time we're not using a LinkedHashMap
, just a standard ArrayList
.
String[] entries = chatList.getEntries();
messages = new ArrayList<>();
for (String msgId : entries) {
addMessage(msgId);
}
adapter = new ChatAdapter(this, messages);
Our addMessage
method just gets the details of each message in the conversation and subscribes to changes on it.
private void addMessage(String msgId) {
Record msgRecord = client.record.getRecord(msgId);
msgRecord.subscribe("content", new ChatItemUpdate(messages.size(), messages, adapter));
JsonObject msgJson = msgRecord.get().getAsJsonObject();
Message m = new Message(
msgJson.get("email").getAsString(),
msgJson.get("content").getAsString(),
msgJson.get("id").getAsString(),
msgJson.get("msgId").getAsString()
);
messages.add(m);
}
One thing that might look off here is the line
msgRecord.subscribe("content", new ChatItemUpdate(messages.size(), messages, adapter));`
So lets take a look into it. The subscribe method we're using is defined as:
public Record subscribe(String path, RecordPathChangedCallback recordPathChangedCallback);
And the ChatItemUpdate
class is defined as:
private class ChatItemUpdate implements RecordPathChangedCallback {
private int position;
private ArrayList<Message> messages;
private ChatAdapter adapter;
ChatItemUpdate(int position, ArrayList<Message> messages, ChatAdapter adapter) {
this.position = position;
this.messages = messages;
this.adapter = adapter;
}
@Override
public void onRecordPathChanged(String recordName, String path, JsonElement data) {
Message msgToEdit = messages.get(position);
msgToEdit.setContent(data.getAsString());
runOnUiThread(new Runnable() {
@Override
public void run() {
adapter.notifyDataSetChanged();
}
});
}
}
What we're doing here is saying that when the content of a message Record
changes, get the message in the position we initialised the wrapper class with earlier, and update it.
Now lets actually send some messages so that we have something to edit. We have a Button
that we've called postButton
, all we need to do is set an OnClickListener
on it. When the button is pressed we get the text from the EditText
and we create a new Record
representing the message. We then add the name of the new Record
to our List
.
postButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
String input = textField.getText().toString();
if (input.isEmpty()) {
return;
}
String msgId = UUID.randomUUID().toString();
String msgName = chatName + "/" + msgId;
Record msgRecord = client.record.getRecord(msgName);
Message message = new Message(
currentUserEmail,
input,
currentUserId,
msgId
);
msgRecord.set(stateRegistry.getGson().toJsonTree(message));
chatList.addEntry(msgName);
textField.setText("");
}
});
As we've seen before, we can just subscribe to the List
so that whenever a new entry is added, it can be updated in the Adapter
. We're also subscribing to content changes in the newly added message the same way we did before.
ListEntryChangedListener entryChangedListener = new ListEntryChangedListener() {
@Override
public void onEntryAdded(String listName, final String msgId, final int position) {
runOnUiThread(new Runnable() {
@Override
public void run() {
addMessage(msgId);
adapter.notifyDataSetChanged();
}
});
}
};
chatList.subscribe(entryChangedListener);
Now that we have messages to edit, we can implement a simple AlertDialog
to handle editing messages. So we set an OnClickListener
on the ListView
, and if the user wrote this message, then an AlertDialog
pops up and they're able to edit it.
listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
Message currentMsg = messages.get(position);
final Record msgRecord = client.record.getRecord(stateRegistry.getCurrentChatName() + "/" + currentMsg.getMsgId());
// don't want to allow editing other peoples messages
if (!currentMsg.getWriterId().equals(stateRegistry.getUserId())) {
return;
}
final EditText editText = new EditText(getApplicationContext());
editText.setText(currentMsg.getContent());
new AlertDialog.Builder(ctx)
.setView(editText)
.setPositiveButton("Ok", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
String newContent = editText.getText().toString();
msgRecord.set("content", newContent);
}
})
.setNegativeButton("Cancel", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
}
})
.show();
}
});
The last thing we need to do here is have an isTyping
notification, so that users can see when the user they're talking to is typing. We already have the state Record
set up for this, so the last step is to update this Record
when a user is typing.
In our XML layout we have a TextView
with ID is_typing_field
, all we need to do is a setText("${user} is typing...")
or setText("")
on this TextView
whenever the isTyping
field in the record changes.
textField = (EditText) findViewById(R.id.input_message);
isTypingField = (TextView) findViewById(R.id.is_typing_field);
textField.addTextChangedListener(new TextWatcher() {
@Override
public void afterTextChanged(Editable s) {
if (s.toString().length() > 0) {
stateRecord.set(stateRegistry.getUserId() + ".isTyping", true);
} else {
stateRecord.set(stateRegistry.getUserId() + ".isTyping", false);
}
}
});
And the associated Record
code:
pathChangedCallback = new RecordPathChangedCallback() {
@Override
public void onRecordPathChanged(String recordName, String path, final JsonElement data) {
runOnUiThread(new Runnable() {
@Override
public void run() {
boolean isTyping = data.getAsBoolean();
if (isTyping) {
isTypingField.setText(otherUserEmail + " is typing...");
} else {
isTypingField.setText("");
}
}
});
}
};
stateRecord.subscribe(otherUserId + ".isTyping", pathChangedCallback);
Now that our realtime chat application is finished, it should look as follows.
Thanks for staying with us, to get a deeper look into deepstreamHub, take a look at our other example apps or our various integrations.