Lesson 21 - Java Chat - Client - Chat Service
In the previous lesson, Java Chat - Server - User Management, we created user management on the server side.
In today's Java tutorial, we're going to start building the basics of the chat. First we'll create a class to send chat messages. We'll also design and implement an interface for the chat service, which will be used to access the chat functions.
Chat Message
In the share
module, in the message
package, we'll
create a new ChatMessage
class to send all chat-related messages.
The class is large, we'll show its source code first and then visualize it on a
diagram:
package cz.stechy.chat.net.message; public class ChatMessage implements IMessage { private static final long serialVersionUID = -7817515518938131863L; public static final String MESSAGE_TYPE = "chat"; private final IChatMessageData data; public ChatMessage(IChatMessageData data) { this.data = data; } @Override public String getType() { return MESSAGE_TYPE; } @Override public Object getData() { return data; } public interface IChatMessageData extends Serializable { ChatMessageDataType getDataType(); Object getData(); } public enum ChatMessageDataType { DATA_ADMINISTRATION, DATA_COMMUNICATION } public static final class ChatMessageAdministrationData implements IChatMessageData { private static final long serialVersionUID = 8237826895694688852L; private final IChatMessageAdministrationData data; public ChatMessageAdministrationData(IChatMessageAdministrationData data) { this.data = data; } @Override public ChatMessageDataType getDataType() { return ChatMessageDataType.DATA_ADMINISTRATION; } @Override public Object getData() { return data; } public enum ChatAction { CLIENT_REQUEST_CONNECT, // chat service connection request CLIENT_CONNECTED, CLIENT_DISCONNECTED, // clients' actions CLIENT_TYPING, CLIENT_NOT_TYPING, // information that somebody is typing } public interface IChatMessageAdministrationData extends Serializable { ChatAction getAction(); } public static final class ChatMessageAdministrationClientRequestConnect implements IChatMessageAdministrationData { private static final long serialVersionUID = 642524654412490721L; private final String id; private final String name; public ChatMessageAdministrationClientRequestConnect(String id, String name) { this.id = id; this.name = name; } public String getId() { return id; } public String getName() { return name; } @Override public ChatAction getAction() { return ChatAction.CLIENT_REQUEST_CONNECT; } } public static final class ChatMessageAdministrationClientState implements IChatMessageAdministrationData { private static final long serialVersionUID = -6101992378764622660L; private final ChatAction action; private final String id; private final String name; public ChatMessageAdministrationClientState(ChatAction action, String id) { this(action, id, ""); } public ChatMessageAdministrationClientState(ChatAction action, String id, String name) { this.id = id; this.name = name; assert action == ChatAction.CLIENT_CONNECTED || action == ChatAction.CLIENT_DISCONNECTED; this.action = action; } public String getId() { return id; } public String getName() { return name; } @Override public ChatAction getAction() { return action; } } public static final class ChatMessageAdministrationClientTyping implements IChatMessageAdministrationData { private static final long serialVersionUID = 630432882631419944L; private final ChatAction action; private final String id; public ChatMessageAdministrationClientTyping(ChatAction action, String id) { assert action == ChatAction.CLIENT_TYPING || action == ChatAction.CLIENT_NOT_TYPING; this.action = action; this.id = id; } public String getId() { return id; } @Override public ChatAction getAction() { return action; } } } public static final class ChatMessageCommunicationData implements IChatMessageData { private static final long serialVersionUID = -2426630119019364058L; private final ChatMessageCommunicationDataContent data; public ChatMessageCommunicationData(String id, byte[] data) { this.data = new ChatMessageCommunicationDataContent(id, data); } @Override public ChatMessageDataType getDataType() { return ChatMessageDataType.DATA_COMMUNICATION; } @Override public Object getData() { return data; } public static final class ChatMessageCommunicationDataContent implements Serializable { private static final long serialVersionUID = -905319575968060192L; private final String destination; private final byte[] data; ChatMessageCommunicationDataContent(String destination, byte[] data) { this.destination = destination; this.data = data; } public String getDestination() { return destination; } public byte[] getData() { return data; } } } }
We'll visualize the class due to its size. See the picture below:
ChatContact
Now we'll create a class to represent one contact of the client. We'll name
the class ChatContact
and place it in the model
package:
public class ChatContact { private final ObservableList <ChatMessageEntry> messages = FXCollections.observableArrayList(); private final StringProperty name = new SimpleStringProperty(this, "name", null); private final ObjectProperty <Color> contactColor = new SimpleObjectProperty<>(this, "contactColor", null); private final IntegerProperty unreadedMessages = new SimpleIntegerProperty(this, "unreadedMessages", 0); private final BooleanProperty typing = new SimpleBooleanProperty(this, "typing", false); private final String id; public ChatContact(String id, String name) { this.id = id; this.name.set(name); contactColor.set(Color.color(Math.random(), Math.random(), Math.random())); } public void addMessage(ChatContact chatContact, String message) { messages.add(new ChatMessageEntry(chatContact, message)); unreadedMessages.set(unreadedMessages.get() + 1); } public void resetUnreadedMessages() { unreadedMessages.set(0); } public void setTyping() { typing.set(true); } public void resetTyping() { typing.set(false); } public ObservableList <ChatMessageEntry> getMessages() { return messages; } @Override public String toString() { return getName(); } }
The messages
instance constant contains a collection of messages
that the user exchanged with that contact. The meaning of the other constants
should be self-explanatory. The typing
BooleanProperty
indicates whether the contact is currently typing a message or not. The
addMessage()
method adds a new message to the messages collection.
We'll use the setTyping()
and resetTyping()
methods to
set whether the contact is typing or not. I'm sure we don't have to describe the
getters and setters here.
We've already used a class that we haven't created yet,
ChatMessageEntry
, so let's add it.
ChatMessageEntry
This class will represent the message itself. Its body will look like this:
package cz.stechy.chat.model; public final class ChatMessageEntry { private final ChatContact chatContact; private final String message; ChatMessageEntry(ChatContact chatContact, String message) { this.chatContact = chatContact; this.message = message; } public ChatContact getChatContact() { return chatContact; } public String getMessage() { return message; } }
The class contains only two properties: chatContact
and
message
.
ChatService
Now let's create an interface that will define the methods for chat:
public interface IChatService { void saveUserId(String id); void sendMessage(String id, String message); void notifyTyping(String id, boolean typing); ObservableMap <String, ChatContact> getClients(); }
The interface defines the most basic functions. The setUserId()
method stores the ID of the user who logged in to the server.
sendMessage()
will send a message. Using the
notifyTyping()
method, we'll notify the other party that we've
started writing a message. The getClients()
method returns an
observable map of all logged-in users.
Implementing the Interface
Next to the interface, we'll create a ChatService
class to
implement it:
package cz.stechy.chat.service; public final class ChatService implements IChatService { private final ObservableMap <String, ChatContact> clients = FXCollections.observableHashMap(); private final List <String> typingInformations = new ArrayList<>(); private final IClientCommunicationService communicator; private String thisUserId; public ChatService(IClientCommunicationService communicator) { this.communicator = communicator; this.communicator.connectionStateProperty().addListener((observable, oldValue, newValue) -> { switch (newValue) { case CONNECTED: this.communicator.registerMessageObserver(ChatMessage.MESSAGE_TYPE, this.chatMessageListener); break; case CONNECTING: break; case DISCONNECTED: this.communicator.unregisterMessageObserver(ChatMessage.MESSAGE_TYPE, this.chatMessageListener); break; } }); } private ChatContact getContactById(String id) { return clients.get(id); } @Override public void saveUserId(String id) { this.thisUserId = id; } @Override public void sendMessage(String id, String message) { final ChatContact chatContact = clients.get(id); if (chatContact == null) { throw new RuntimeException("Client not found."); } byte[] messageData = (message + " ").getBytes(); communicator.sendMessage( new ChatMessage( new ChatMessageCommunicationData(id, messageData))); chatContact.addMessage(clients.get(thisUserId), message); } @Override public void notifyTyping(String id, boolean typing) { if (typing && typingInformations.contains(id)) { return; } communicator.sendMessage(new ChatMessage( new ChatMessageAdministrationData( new ChatMessageAdministrationClientTyping( typing ? ChatAction.CLIENT_TYPING : ChatAction.CLIENT_NOT_TYPING, id)))); if (typing) { typingInformations.add(id); } else { typingInformations.remove(id); } } @Override public ObservableMap <String, ChatContact> getClients() { return clients; } private final OnDataReceivedListener chatMessageListener = message -> {}; }
The class contains three instance constants:
clients
- An observable map of all logged-in userstypingInformations
- A collection of users who are currently writing a messagecommunicator
- A service mediating the communication with the server.
We get the communicator in the constructor and store it. Next, we set a
listener to changes of the connection state. This is because we want to
respond to incoming messages only when we're connected. In the
sendMessage()
method, we create a new message with the specified
content and use the communicator to send it to the server. Next we add this
message to the list of "received" messages. The notifyTyping()
method is used to inform whether we have informed the user on the other side
that we've started / stopped writing. We use the typingInformations
register to avoid sending a message every time we type a character. The
getClients()
method returns an observable map of all logged-in
clients. Finally, there's the chatMessageListener
variable, which
contains an anonymous OnDataReceivedListener()
function. We'll now
complete this function together.
OnDataReceivedListener
To get started, we'll cast the received message to the
ChatMessage
class and use the getData()
method to get
the IChatMessageData
interface:
final ChatMessage chatMessage = (ChatMessage) message; final IChatMessageData messageData = (IChatMessageData) chatMessage.getData();
We'll get the data type from the messageData
variable using the
getDataType()
method. We'll make a switch
to decide
how to process the data type:
switch (messageData.getDataType()) { case DATA_ADMINISTRATION: break; case DATA_COMMUNICATION: break; default: throw new IllegalArgumentException("Invalid parameter."); }
The getDataType()
method returns one of two values in the
ChatMessageDataType
enumeration. For administrative data, we'll
process messages such as:
CLIENT_CONNECTED
/CLIENT_DISCONNECTED
CLIENT_TYPING
/CLIENT_NOT_TYPING
If data of the DATA_COMMUNICATION
type arrives, we know there's
a message to display.
DATA_ADMINISTRATION
case DATA_ADMINISTRATION: final ChatMessageAdministrationData administrationData = (ChatMessageAdministrationData) messageData; final IChatMessageAdministrationData data = (IChatMessageAdministrationData) administrationData.getData(); switch (data.getAction()) { case CLIENT_CONNECTED: final ChatMessageAdministrationClientState messageAdministrationClientConnected = (ChatMessageAdministrationClientState) data; final String connectedClientID = messageAdministrationClientConnected.getId(); final String connectedClientName = messageAdministrationClientConnected.getName(); Platform.runLater(() -> clients.putIfAbsent(connectedClientID, new ChatContact(connectedClientID, connectedClientName))); break; case CLIENT_DISCONNECTED: final ChatMessageAdministrationClientState messageAdministrationClientDiconnected = (ChatMessageAdministrationClientState) data; final String disconnectedClientID = messageAdministrationClientDiconnected.getId(); Platform.runLater(() -> clients.remove(disconnectedClientID)); break; case CLIENT_TYPING: final ChatMessageAdministrationClientTyping messageAdministrationClientTyping = (ChatMessageAdministrationClientTyping) data; final String typingClientId = messageAdministrationClientTyping.getId(); final ChatContact typingClient = getContactById(typingClientId); Platform.runLater(typingClient::setTyping); break; case CLIENT_NOT_TYPING: final ChatMessageAdministrationClientTyping messageAdministrationClientNoTyping = (ChatMessageAdministrationClientTyping) data; final String noTypingClientId = messageAdministrationClientNoTyping.getId(); final ChatContact noTypingClient = getContactById(noTypingClientId); Platform.runLater(noTypingClient::resetTyping); break; default: throw new IllegalArgumentException("Invalid argument."); } break;
First, we extract information about the administrative data. The
getAction()
method gets the action that the message represents.
Based on this action we decide in the switch
how we'll process the
message. Most of the code is about getting the data itself. The action to be
performed is then called using Platform.runLater()
.
When a communication message arrives, we'll show it to the user:
case DATA_COMMUNICATION: final ChatMessageCommunicationData communicationData = (ChatMessageCommunicationData) messageData; final ChatMessageCommunicationDataContent communicationDataContent = (ChatMessageCommunicationDataContent) communicationData.getData(); final String destination = communicationDataContent.getDestination(); final byte[] messageRaw = communicationDataContent.getData(); final String messageContent = new String(messageRaw, StandardCharsets.UTF_8); Platform.runLater(() -> { if (clients.containsKey(destination)) { final ChatContact chatContact = clients.get(destination); chatContact.addMessage(chatContact, messageContent); } }); break;
Note that we don't care how the message is displayed to the user. We just add a new message to the selected contact. Another layer will take care of the rest.
That'd be all for today's lesson.
Next time, in the lesson Java Chat - Server - Chat Plugin, we'll move to the server again and create a
plugin that will take care of communicating with our
ChatService
.