JLUPIN PLATFORM WEBSITE
tutorials
  • Rating:
  • Views: 223
  • Author: JLupin
  • Skill level: medium
  • Comments: 0

Overview

This tutorial will show you how to create example chat/event application. The client will be running on Android devices while engine will be running on JLupin Platform with usage of JLupin Reactive Queues and Channels. For communication between client and engine websockets technology will be used. Client will use JLupin Edge Balancer as an entry point.

Overview

Requirements

To complete this tutorial you will need JLupin Platform version 1.6.1.0. You can download it from here. You will also need IntelliJ with installed JLupin Platform Development Tool plugin and Android Studio.


General information

Whole project will be devided into two parts. First part will be created with IntelliJ and will contain engine code which will be run on JLupin Platform. Second part will be create with Android Studio.

Application scheme

Above is shown scheme of application. Keep in mind that every microservice could have more than one instance. It is very important because websocket connection is created only to one instance of chat microservice. That's why it is required to use channels for communication between chat-processor and chat. Channels are created for point to point communication so we can be shure that sent message will be send to chat instance which has opened websocket connection. Due to that there could be multiple instances of each microservice we will also notify all chat-processor instances through queue. That's because we won't store channels' ids in some shared storage. Instead every chat-processor instance will have some part of clients connected to particular channel.

Let's see what will happen in our application. For android device we will reffer as a client and for all other parts we will use names from scheme.

Before we will take a look at communication let's define simple protocol over websocket connection. First message from client to chat will be username of logged in user. Second message from client to chat will be channel's name to which user is connecting. Then all messages from client to chat will be treated as sent messages and all messages from chat to client will be treated as received messages. Username, channel's name and sent message will use plain text format. Received messages will use JSON format.

CLIENT                                                           CHAT
  |                                                               |
  | send username                                                 |
  | ex. [peter]                                                   |
  +-------------------------------------------------------------->|
  |                                                               |
  | send channel's name                                           |
  | ex. [test]                                                    |
  |                                                               |
  +-------------------------------------------------------------->|
  .                                                               .
  .                                                               .
  |                                                               |
  | send message                                                  |
  | ex. [Hello!]                                                  |
  |-------------------------------------------------------------->|
  |                                                               |
  .                                                               .
  .                                                               .
  |                                                               |
  | receive message                                               |
  | ex. {"created_at":1574928172,"message":"Hi!","user":"thomas"} |
  |<--------------------------------------------------------------|
  |                                                               |
  .                                                               .
  .                                                               .
  |                                                               |

Sending and receiving messages could happen parallel and there are no requirements for their correlation. Now let's see how everything should work in general:

Application scheme - connecting to channel
  1. user enters username and channel name
  2. client creates websocket connection to chat
  3. client send username to chat
  4. chat asks chat-processor for user
  5. chat-processor asks chat-db for user
  6. chat-db get user form database (in this example form inner map) and returns it to chat-processor
  7. chat-processor returns user to chat
  8. chat checks if the user was return (user exists) and waits for channel's name
  9. client send channel name to chat
  10. chat asks chat-processor to join user to channel
  11. chat-processor creates new channel, saves in map under joined channel's name and returns channel id to chat
  12. chat bind to received channel id for messages

After above events client and chat can send messages to each other. And message flow should look like this:

Application scheme - sneding message
  1. user enters message and click send button
  2. client send message to chat
  3. chat knows who send message (it has websocket session id) and put asynchronous task in queue to execute send message method on every chat-processor (with text, user, channel name and creation time)
  4. every chat-processor gets its list of clients connected to channel (list of channel ids) with such a name and sends message to all of them (asynchronously through channel by id)
  5. every listener for channel id in chat gets new message, serializes it to JSON and send through websocket connectino to clients
  6. client gets message and based on sender username and logged username decides how to render message - as a received or sent one

Part I


Create project

First step will be project creation. From top menu select File -> New -> Project. If you had installed JLupin Platform Development Tool plugin you should see on the left JLupin Platform project type. Select it and the choose Three layer project. Go to the next step.

For this tutorial use com.example.chat as a group id and for platform version type 1.6.1.0. Next three steps are simple. You need to add microservices names for each layer. Don't forget to add name to the list with Add button after putting it in form or it won't be created. The names for microservices should be: chat for Access Layer, chat-processor for Business Logic Layer, chat-db for Data Layer.

After creating new project wait a few seconds to let IntelliJ create all files and index them. Created project is using Maven and multi modules. You can read more about it in documentation.


Engine development


General

At first create some common models for data. They should be places in common-pojo module in package com.example.chat.common.pojo.

package com.example.chat.common.pojo;

import java.io.Serializable;
import java.sql.Date;

public class Message implements Serializable {
    private final Date createdAt;
    private final String message;
    private final User user;

    public Message(final Date createdAt, final String message, final User user) {
        this.createdAt = createdAt;
        this.message = message;
        this.user = user;
    }

    public Date getCreatedAt() {
        return createdAt;
    }

    public String getMessage() {
        return message;
    }

    public User getUser() {
        return user;
    }
}
package com.example.chat.common.pojo;

import java.io.Serializable;
import java.sql.Date;

public class User implements Serializable {
    private final Date createdAt;
    private final String username;

    public User(final Date createdAt, final String username) {
        this.createdAt = createdAt;
        this.username = username;
    }

    public Date getCreatedAt() {
        return createdAt;
    }

    public String getUsername() {
        return username;
    }
}

Because this models will are transferred between microservices during remote calls they must implement Serializable interface.


chat-db

Now let's move to chat-db microservice. Database will be simulated in DAO class with map. To create DAO class expand project's file list to see chat-db-implementation module (directory path is DataLayer/chat-db-data/implementation). Click with right mouse button on it (in pane called Project) and select New -> New DAO. Fill name input with User and click ok. JLupin plugin will create two classes for you. One will be an interface UserDAO and the other one will be class named UserDAOImpl which will implement UserDAO interface and have proper annotation for Spring Container. Both of them will be placed in proper packages.

Now create UserEntity class in package com.example.chat.dao.pojo. It will simulate database object.

package com.example.chat.dao.pojo;

import java.sql.Date;

public class UserEntity {
    private final Date createdAt;
    private final String username;

    public UserEntity(final Date createdAt, final String username) {
        this.createdAt = createdAt;
        this.username = username;
    }

    public Date getCreatedAt() {
        return createdAt;
    }

    public String getUsername() {
        return username;
    }
}

And now you can implement created UserDAO service.

package com.example.chat.dao.interfaces;

import com.example.chat.dao.pojo.UserEntity;

public interface UserDAO {
    UserEntity createUser(final String username);

    UserEntity findByUsername(final String username);
}
package com.example.chat.dao.impl;

import com.example.chat.dao.interfaces.UserDAO;
import com.example.chat.dao.pojo.UserEntity;
import org.springframework.stereotype.Repository;

import java.sql.Date;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

@Repository(value = "userDAO")
public class UserDAOImpl implements UserDAO {
    private final ConcurrentMap users;

    public UserDAOImpl() {
        this.users = new ConcurrentHashMap();
    }

    @Override
    public UserEntity createUser(final String username) {
        if (users.containsKey(username)) {
            return null;
        }

        final UserEntity userEntity = new UserEntity(
                new Date(System.currentTimeMillis()), username
        );

        users.put(username, userEntity);

        return userEntity;
    }

    @Override
    public UserEntity findByUsername(final String username) {
        if (!users.containsKey(username)) {
            return null;
        }

        return users.get(username);
    }
}

You have created connector to database (which in this case is a simple map). Now you must share access to it with others. Of course it is possible to just make DAO object accessible from the outside but it is better to create special service for communication. Again click right mouse button on chat-db-implementation module and select New -> New Servcie option. As a name provide UserDB and click ok. Again plugin will set up everything for you: create interface class in user-db-interfaces module, create implementation class in user-db-implementation module with proper Spring annotations and also will add service name to the list of externally exposed services (the list is a bean with name jLupinRegularExpressionToRemotelyEnabled and can be found in configuration file for Spring context - ChatDbSpringConfiguration). All files will be also placed in proper packages.

All you need to do now is to put code into created classes.

package com.example.chat.service.interfaces;

import com.example.chat.common.pojo.User;

public interface UserDBService {
    User findByUsername(final String username);
}
package com.example.chat.service.impl;

import com.example.chat.common.pojo.User;
import com.example.chat.dao.interfaces.UserDAO;
import com.example.chat.dao.pojo.UserEntity;
import com.example.chat.service.interfaces.UserDBService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.Objects;

@Service(value = "userDBService")
public class UserDBServiceImpl implements UserDBService {
    @Autowired
    private UserDAO userDAO;

    @Override
    public User findByUsername(final String username) {
        final UserEntity userEntity = userDAO.findByUsername(username);
        return convertUserEntity(userEntity);
    }

    private User convertUserEntity(final UserEntity userEntity) {
        if (Objects.isNull(userEntity)) {
            return null;
        }

        return new User(
                userEntity.getCreatedAt(),
                userEntity.getUsername()
        );
    }
}

Now you should have fully working Data Layer for our chat/event application. You need to do only one more thing. When client will log in out system will check if user is present in database so we need to add some users to make sure we can log in. Modify ChatDbSpringConfiguration class by adding afterStart() method.

package com.example.chat.configuration;

import com.example.chat.dao.interfaces.UserDAO;
import com.jlupin.impl.client.util.JLupinClientUtil;
import com.jlupin.interfaces.client.delegator.JLupinDelegator;
import com.jlupin.interfaces.common.enums.PortType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;

@Configuration
@ComponentScan("com.example.chat")
public class ChatDbSpringConfiguration {
    @Autowired
    private ApplicationContext applicationContext;

    @Bean
    public JLupinDelegator getJLupinDelegator() {
        return JLupinClientUtil.generateInnerMicroserviceLoadBalancerDelegator(PortType.JLRMC);
    }

    @Bean(name = "jLupinRegularExpressionToRemotelyEnabled")
    public List getRemotelyBeanList() {
        List<String> list = new ArrayList<>();
        list.add("userDBService");
        return list;
    }

    @PostConstruct
    public void afterStart() {
        final UserDAO userDAO = applicationContext.getBean(UserDAO.class);

        userDAO.createUser("peter");
        userDAO.createUser("thomas");
        userDAO.createUser("mark");
    }
}

chat-processor

Now let's move to chat-processor microservice and start with some theory how it will work. As said before we will use JLupin's channels to transport messages to websockets and then to clients. Because every client has it's own websocket connection we must create something like broadcast over websockets. Because we will connect one websocket with one channel we can move this problem to broadcasting over channels. Because we wan't to broadcast only to clients connected to same chat's channel we could store all JLupin channels ids grouped by chat channel name. We move it to seperate bean (internal service). To do so click with right mouse button on chat-processor-implementation module (directory path is BusinessLogicLayer/chat-processor-business-logic/implementation) and select New -> New Bean. As a name use ChatChannelStore. Again JLupin plugin will create interface and implementation class for you with proper annotations. Just put implementation code inside.

package com.example.chat.bean.interfaces;

import com.example.chat.common.pojo.User;

import java.util.List;

public interface ChatChannelStoreBean {
    String addUserToChannel(final String channelName, final User user) throws Exception;

    List<String> getAllStreamIdsForChannel(final String channelName);
    void removeChannelStreamId(final String channelStreamId);
}
package com.example.chat.bean.impl;

import com.example.chat.bean.interfaces.ChatChannelStoreBean;
import com.example.chat.common.pojo.User;
import com.jlupin.impl.client.util.channel.JLupinClientChannelUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

@Service(value = "chatChannelStoreBean")
public class ChatChannelStoreBeanImpl implements ChatChannelStoreBean {
    @Autowired
    private JLupinClientChannelUtil jlupinClientChannelUtil;

    private ConcurrentMap<String, List<String>> channelNameToStreamIds;

    public ChatChannelStoreBeanImpl() {
        this.channelNameToStreamIds = new ConcurrentHashMap<>();
    }

    @Override
    public String addUserToChannel(final String channelName, final User user) throws Exception {
        channelNameToStreamIds.putIfAbsent(channelName, new ArrayList<>());
        final List<String> streamIds = channelNameToStreamIds.get(channelName);

        final String streamId = jlupinClientChannelUtil.openStreamChannel();

        streamIds.add(streamId);

        return streamId;
    }

    @Override
    public List<String> getAllStreamIdsForChannel(final String channelName) {
        return new ArrayList<>(channelNameToStreamIds.getOrDefault(channelName, Collections.emptyList()));
    }

    @Override
    public void removeChannelStreamId(final String channelStreamId) {
        for (final List<String> streamIds : channelNameToStreamIds.values()) {
            streamIds.remove(channelStreamId);
        }
    }
}

As you may noticed this store is also responsible of creation of JLupin channels instances. It is using injected instance of JLupinClientChannelUtil. We will define it later in configuration class. List of JLupin channels' ids are stored under chat channel name as a key.

Now add externally available services. We will create two of them - UserService and ChatProcessorService. To do so right click on chat-processor-implementation module and select New -> New Service. First time put User name and click ok. Second time put ChatProcessor and click on. Again plugin will do everything for you. Just add code to classes but remember - interfaces are created in seperate module called chat-processor-interfaces.

package com.example.chat.service.interfaces;

import com.example.chat.common.pojo.User;

public interface UserService {
    User getUserByUsername(final String username);
}
package com.example.chat.service.impl;

import com.example.chat.common.pojo.User;
import com.example.chat.service.interfaces.UserDBService;
import com.example.chat.service.interfaces.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service(value = "userService")
public class UserServiceImpl implements UserService {
    @Autowired
    private UserDBService userDBService;

    @Override
    public User getUserByUsername(final String username) {
        return userDBService.findByUsername(username);
    }
}
package com.example.chat.service.interfaces;

import com.example.chat.common.pojo.Message;
import com.example.chat.common.pojo.User;
import com.example.chat.service.pojo.ChatDetails;

public interface ChatProcessorService {
    ChatDetails connectToChat(final String channelName, final User user) throws Exception;

    void sendMessage(final String channelName, final Message message);
    void closeStream(final String channelStreamId);
}
package com.example.chat.service.impl;

import com.example.chat.bean.interfaces.ChatChannelStoreBean;
import com.example.chat.common.pojo.Message;
import com.example.chat.common.pojo.User;
import com.example.chat.service.interfaces.ChatProcessorService;
import com.example.chat.service.pojo.ChatDetails;
import com.jlupin.impl.client.util.channel.JLupinClientChannelUtil;
import com.jlupin.impl.client.util.channel.exception.JLupinClientChannelUtilException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Objects;

@Service(value = "chatProcessorService")
public class ChatProcessorServiceImpl implements ChatProcessorService {
    private static final Logger logger = LoggerFactory.getLogger(ChatProcessorServiceImpl.class);

    @Autowired
    private ChatChannelStoreBean chatChannelStoreBean;
    @Autowired
    private JLupinClientChannelUtil jlupinClientChannelUtil;

    @Override
    public ChatDetails connectToChat(final String channelName, final User user) throws Exception {
        final String channelStreamId = chatChannelStoreBean.addUserToChannel(channelName, user);

        return new ChatDetails(channelStreamId);
    }

    @Override
    public void sendMessage(final String channelName, final Message message) {
        final List<String> allStreamIdsForChannel = chatChannelStoreBean.getAllStreamIdsForChannel(channelName);

        if (Objects.isNull(allStreamIdsForChannel)) {
            return;
        }

        for (final String channelStreamId : allStreamIdsForChannel) {
            try {
                jlupinClientChannelUtil.putNextElementToStreamChannel(
                        channelStreamId, message
                );
            } catch (JLupinClientChannelUtilException e) {
                logger.error("Error sending message to streamId: " + channelStreamId, e);
            }
        }
    }

    public void closeStream(final String channelStreamId) {
        chatChannelStoreBean.removeChannelStreamId(channelStreamId);
        try {
            jlupinClientChannelUtil.closeStreamChannel(channelStreamId);
        } catch (JLupinClientChannelUtilException e) {
            logger.error("Error while closing streamId: " + channelStreamId, e);
        }
    }
}

Code above contains undefined class ChatDetails. Because it is class used in externally available servcie add it to chat-processor-interfacees module in package com.example.chat.service.pojo. This way it will be available for clients to call service.

package com.example.chat.service.pojo;

import java.io.Serializable;

public class ChatDetails implements Serializable {
    private final String channelStreamId;

    public ChatDetails(final String channelStreamId) {
        this.channelStreamId = channelStreamId;
    }

    public String getChannelStreamId() {
        return channelStreamId;
    }
}

Implementing Serializable interface here is very important - objects of this class will be serialized and transferred between microservices.

In ChatProcessorServiceImpl you can see exactly what was said before. When new message is received all connected clients are taken out from store and message is sent to all of them.

Getting back to defined UserService - it is using external service UserDBService. And for now chat-processor-implementation module is missing two things: dependency to chat-db-interfaces module and configuration how to create instance of UserDBService on the client side (in this case chat-processor-implementation is a client). Below is showed updated configuration file ChatProcessorConfigurationFile.

package com.example.chat.configuration;

import com.example.chat.service.interfaces.UserDBService;
import com.jlupin.impl.client.util.JLupinClientUtil;
import com.jlupin.impl.client.util.channel.JLupinClientChannelUtil;
import com.jlupin.interfaces.client.delegator.JLupinDelegator;
import com.jlupin.interfaces.common.enums.PortType;
import com.jlupin.interfaces.microservice.partofjlupin.asynchronous.service.channel.JLupinChannelManagerService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

import java.util.ArrayList;
import java.util.List;

@Configuration
@ComponentScan("com.example.chat")
public class ChatProcessorSpringConfiguration {
    @Bean
    public JLupinDelegator getJLupinDelegator() {
        return JLupinClientUtil.generateInnerMicroserviceLoadBalancerDelegator(PortType.JLRMC);
    }

    @Bean
    public JLupinChannelManagerService getJLupinChannelManagerService() {
        return JLupinClientUtil.generateRemote(getJLupinDelegator(), "channelMicroservice", "jLupinChannelManagerService" , JLupinChannelManagerService.class);
    }

    @Bean
    public JLupinClientChannelUtil getJLupinClientChannelUtil() {
        return new JLupinClientChannelUtil("CHAT_CHANNEL", getJLupinChannelManagerService());
    }

    @Bean
    public UserDBService getUserDBService() {
        return JLupinClientUtil.generateRemote(getJLupinDelegator(), "chat-db", UserDBService.class);
    }

    @Bean(name = "jLupinRegularExpressionToRemotelyEnabled")
    public List getRemotelyBeanList() {
        List<String> list = new ArrayList<>();
        list.add("chatProcessorService");
        list.add("userService");
        return list;
    }
}

As you can see there are defined two dependecies used in ChatProcessorService and UserService - JLupinClientChannelUtil and UserDBService. It is standard JLupin configuration for remote calls and reactive channels usage. Channel name defined here is "CHAT_CHANNEL" and later we will define it in configuration of channelMicroservice.

And that's all for chat-processor microservice.


chat

Now let's move to chat microservice. It will be a little bit different - as it is a servlet type microservice. We won't "expose" services here. For communication only websockets will be used.

Because websockets does not have any type of session we will store objects objects shared between messages in a map. The key will be a session id. Same session id means same socket connection and same client. Let's start with creation of package com.example.chat.websocket.handler. In this package create ChannelWebSocketStore.

package com.example.chat.websocket.handler;

import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketSession;

import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

@Component
public class ChannelWebSocketStore {
    private ConcurrentMap<String, ConcurrentMap<String, Object>> sessionStore;

    public ChannelWebSocketStore() {
        sessionStore = new ConcurrentHashMap<>();
    }

    public void addToSession(final WebSocketSession session, final String key, final Object obj) {
        final ConcurrentMap<String, Object> currentSessionStore = sessionStore.get(session.getId());

        if (Objects.isNull(currentSessionStore)) {
            return;
        }

        currentSessionStore.put(key, obj);
    }

    public Object getFromSession(final WebSocketSession session, final String key) {
        final ConcurrentMap<String, Object> currentSessionStore = sessionStore.get(session.getId());

        if (Objects.isNull(currentSessionStore)) {
            return null;
        }

        return currentSessionStore.get(key);
    }

    public void newSession(final WebSocketSession session) {
        sessionStore.put(session.getId(), new ConcurrentHashMap<>());
    }

    public void removedSession(final WebSocketSession session) {
        sessionStore.remove(session.getId());
    }
}

We will also provide a little state machine for communication protocol over websockets. Add enum for states.

package com.example.chat.websocket.handler;

public enum ChannelWebSocketState {
    SELECT_USER, SELECT_CHANNEL, RECEIVE_MESSAGE
}

Created protocol will be very simple. Client will send username, then channel's name and then all data would be treated as messages. Username will be checked if it is present in database. Please also create class ChannelWebSocketContext. Context will be created for every new websocket connection and this class will store all context's objects. Of course it could store objects in map under some keys but then they would have a general object type - it is better to keep object types if it is possible. Context class is shown below.

package com.example.chat.websocket.handler;

import com.example.chat.common.pojo.User;
import com.example.chat.service.pojo.ChatDetails;
import org.springframework.web.socket.WebSocketSession;

import java.util.Objects;

public class ChannelWebSocketContext {
    private ChannelWebSocketStore channelWebSocketStore;
    private WebSocketSession webSocketSession;

    public ChannelWebSocketContext(
            final ChannelWebSocketStore channelWebSocketStore, final WebSocketSession webSocketSession
    ) {
        this.channelWebSocketStore = channelWebSocketStore;
        this.webSocketSession = webSocketSession;
    }

    public WebSocketSession getWebSocketSession() {
        return webSocketSession;
    }

    public User getCurrentUser() throws Exception {
        final User user = (User) channelWebSocketStore.getFromSession(webSocketSession, "user");

        if (Objects.isNull(user)) {
            throw new Exception("User not selected");
        }

        return user;
    }

    public void setCurrentUser(final User user) throws Exception {
        final Object alreadySetUser = channelWebSocketStore.getFromSession(webSocketSession, "user");
        if (Objects.nonNull(alreadySetUser)) {
            throw new Exception("User already selected");
        }

        channelWebSocketStore.addToSession(webSocketSession, "user", user);
    }

    public String getCurrentChannelName() throws Exception {
        final String channelName = (String) channelWebSocketStore.getFromSession(webSocketSession, "channelName");

        if (Objects.isNull(channelName)) {
            throw new Exception("Channel name not selected");
        }

        return channelName;
    }

    public void setCurrentChannelName(final String channelName) throws Exception {
        final String alreadySetChannelName = (String) channelWebSocketStore.getFromSession(webSocketSession, "channelName");
        if (Objects.nonNull(alreadySetChannelName)) {
            throw new Exception("Channel name already selected");
        }

        channelWebSocketStore.addToSession(webSocketSession, "channelName", channelName);
    }

    public ChatDetails getCurrentChatDetails() throws Exception {
        final ChatDetails chatDetails = (ChatDetails) channelWebSocketStore.getFromSession(webSocketSession, "chatDetails");

        if (Objects.isNull(chatDetails)) {
            throw new Exception("Chat details not selected");
        }

        return chatDetails;
    }

    public void setCurrentChatDetails(final ChatDetails chatDetails) throws Exception {
        final ChatDetails alreadySetChatDetails = (ChatDetails) channelWebSocketStore.getFromSession(webSocketSession, "chatDetails");
        if (Objects.nonNull(alreadySetChatDetails)) {
            throw new Exception("Chat details already selected");
        }

        channelWebSocketStore.addToSession(webSocketSession, "chatDetails", chatDetails);
    }

    public Thread getCurrentListeningThread() throws Exception {
        final Thread listeningThread = (Thread) channelWebSocketStore.getFromSession(webSocketSession, "listeningThread");

        if (Objects.isNull(listeningThread)) {
            throw new Exception("Listening thread not set");
        }

        return listeningThread;
    }

    public void setCurrentListeningThread(final Thread listeningThread) throws Exception {
        final Thread alreadySetListeningThread = (Thread) channelWebSocketStore.getFromSession(webSocketSession, "listeningThread");
        if (Objects.nonNull(alreadySetListeningThread)) {
            throw new Exception("Listening thread already set");
        }

        channelWebSocketStore.addToSession(webSocketSession, "listeningThread", listeningThread);
    }

    public ChannelWebSocketState getCurrentState() throws Exception {
        final ChannelWebSocketState state = (ChannelWebSocketState) channelWebSocketStore.getFromSession(webSocketSession, "state");

        if (Objects.isNull(state)) {

            throw new Exception("State is not set");
        }

        return state;
    }

    public void setCurrentState(final ChannelWebSocketState state) throws Exception {
        final Object alreadySetState = channelWebSocketStore.getFromSession(webSocketSession, "state");

        if (Objects.isNull(alreadySetState)) {
            if (state.equals(ChannelWebSocketState.SELECT_USER)) {
                channelWebSocketStore.addToSession(webSocketSession, "state", state);
                return;
            }
        } else {
            if (
                    (
                            alreadySetState.equals(ChannelWebSocketState.SELECT_USER)
                                    &&
                                    state.equals(ChannelWebSocketState.SELECT_CHANNEL)
                    ) || (
                            alreadySetState.equals(ChannelWebSocketState.SELECT_CHANNEL)
                                    &&
                                    state.equals(ChannelWebSocketState.RECEIVE_MESSAGE)
                    )
            ) {
                channelWebSocketStore.addToSession(webSocketSession, "state", state);
                return;
            }
        }

        throw new Exception("State cannot transition from " + alreadySetState + " to " + state);
    }

    public void create() {
        channelWebSocketStore.newSession(webSocketSession);
    }

    public void remove() {
        channelWebSocketStore.removedSession(webSocketSession);
    }
}

Now create ChatWebSocketHandlerImpl class. It will be responsible for handling websocket connections and messages.

package com.example.chat.websocket.handler;

import com.example.chat.common.pojo.Message;
import com.example.chat.common.pojo.User;
import com.example.chat.service.interfaces.ChatProcessorService;
import com.example.chat.service.interfaces.UserService;
import com.example.chat.service.pojo.ChatDetails;
import com.jlupin.impl.client.util.channel.JLupinClientChannelIterableProducer;
import com.jlupin.impl.client.util.publisher.JLupinClientPublisherUtil;
import com.jlupin.impl.client.util.publisher.exception.JLupinClientPublisherUtilException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;
import java.sql.Date;
import java.util.Iterator;
import java.util.Objects;

@Component
public class ChannelWebSocketHandlerImpl extends TextWebSocketHandler {
    private static final Logger logger = LoggerFactory.getLogger(ChannelWebSocketHandlerImpl.class);

    private static final String ENDPOINT = "/channel";

    @Autowired
    private JLupinClientPublisherUtil jlupinClientPublisherUtil;
    @Autowired
    private JLupinClientChannelIterableProducer jlupinClientChannelIterableProducer;
    @Autowired
    private ChatProcessorService chatProcessorService;
    @Autowired
    private UserService userService;
    @Autowired
    private ChannelWebSocketStore webSocketStore;

    public String getEndpoint() {
        return ENDPOINT;
    }

    @Override
    public void afterConnectionEstablished(final WebSocketSession session) throws Exception {
        logger.debug("\n[{}] New websocket session",  session.getId());

        final ChannelWebSocketContext webSocketContext = new ChannelWebSocketContext(
                webSocketStore, session
        );
        webSocketContext.create();

        webSocketContext.setCurrentState(ChannelWebSocketState.SELECT_USER);
    }

    @Override
    public void afterConnectionClosed(final WebSocketSession session, final CloseStatus status) throws Exception {
        logger.debug("\n[{}] Closed websocket session", session.getId());

        final ChannelWebSocketContext webSocketContext = new ChannelWebSocketContext(
                webSocketStore, session
        );

        try {
            closeChanel(webSocketContext);
        } finally {
            webSocketContext.remove();
        }
    }

    @Override
    protected void handleTextMessage(final WebSocketSession session, final TextMessage message) throws Exception {
        final String messageText = message.getPayload();

        logger.debug("\n[{}] New message received: {}", session.getId(), messageText);

        final ChannelWebSocketContext webSocketContext = new ChannelWebSocketContext(
                webSocketStore, session
        );

        final ChannelWebSocketState state = webSocketContext.getCurrentState();

        if (Objects.isNull(state)) {
            throw new Exception("State should be set");
        }

        switch (state) {
            case SELECT_USER:
                loginUser(webSocketContext, messageText);
                break;
            case SELECT_CHANNEL:
                joinChannel(webSocketContext, messageText);
                break;
            case RECEIVE_MESSAGE:
                sendMessage(webSocketContext, messageText);
                break;
            default:
                throw new Exception("Unsupported state");
        }
    }

    private void loginUser(final ChannelWebSocketContext webSocketContext, final String username) throws Exception {
        final User user = userService.getUserByUsername(username);

        if (Objects.isNull(user)) {
            throw new Exception("User not found");
        }

        webSocketContext.setCurrentUser(user);
        webSocketContext.setCurrentState(ChannelWebSocketState.SELECT_CHANNEL);
    }

    private void joinChannel(
            final ChannelWebSocketContext webSocketContext, final String channelName
    ) throws Exception {
        final User user = webSocketContext.getCurrentUser();
        final ChatDetails chatDetails = chatProcessorService.connectToChat(channelName, user);

        webSocketContext.setCurrentChatDetails(chatDetails);

        final WebSocketSession session = webSocketContext.getWebSocketSession();

        final Thread t = new Thread(() -> {
            final Iterable iterable = jlupinClientChannelIterableProducer.produceChannelIterable(
                    3600000L, 1000L, "CHAT_CHANNEL", chatDetails.getChannelStreamId()
            );
            final Iterator iterator = iterable.iterator();

            logger.debug("\n[{}] Start listening for messages", session.getId());

            while (iterator.hasNext()) {
                Message message = (Message) iterator.next();
                try {
                    logger.debug("\n[{}] Sending message: {}",  session.getId(), message.getMessage());

                    session.sendMessage(new TextMessage(
                            "{" +
                            "\"created_at\":" + message.getCreatedAt().getTime() + "," +
                            "\"message\":\"" + message.getMessage() + "\"," +
                            "\"user\":\"" + message.getUser().getUsername() + "\"" +
                            "}"
                    ));
                } catch (IOException e) {
                    logger.error("Error sending message, closing session", e);
                    try {
                        session.close();
                    } catch (IOException ex) {
                        logger.error("Error closing session", e);
                    }
                }
            }

            logger.debug("\n[{}] Stop listening for messages", session.getId());
        });

        webSocketContext.setCurrentListeningThread(t);
        webSocketContext.setCurrentChannelName(channelName);

        t.start();

        webSocketContext.setCurrentState(ChannelWebSocketState.RECEIVE_MESSAGE);
    }

    private void sendMessage(
            final ChannelWebSocketContext webSocketContext, final String message
    ) throws Exception {
        final User user = webSocketContext.getCurrentUser();
        final String channelName = webSocketContext.getCurrentChannelName();

        try {
            jlupinClientPublisherUtil.publishTaskToAllSubscribers(
                    "chat-processor",
                    "chatProcessorService",
                    "sendMessage",
                    new Object[]{
                            channelName,
                            new Message(
                                    new Date(System.currentTimeMillis()),
                                    message,
                                    user
                            )
                    }
            );
        } catch (JLupinClientPublisherUtilException e) {
            throw new Exception("Error sending message to all subscribers", e);
        }
    }

    private void closeChanel(
            final ChannelWebSocketContext webSocketContext
    ) throws Exception {
        final ChatDetails chatDetails = webSocketContext.getCurrentChatDetails();
        chatProcessorService.closeStream(chatDetails.getChannelStreamId());

        final Thread currentListeningThread = webSocketContext.getCurrentListeningThread();
        currentListeningThread.join(1000);
        currentListeningThread.interrupt();
    }
}

The class is extending TextWebSocketHandler because we will use text communication here (instead of binary). First 3 methods (afterConnectionEstablished, afterConnectionClosed, handleTextMessage) are the points to integrate with Spring Boot's websockets. When new connection is established we are creating web socket session for it (we wan't to store data between messages) and of course when connection is closed we are removing it. Handling messages varies based on current state of protocol. Each state handling is moved to appropriate method.

Loging user is simple - we are checking if they user is present in database and then we can set it in context and change state of protocol. Joining channel is much more complicated. First of all we are opening stream for communication with this particular user (being precise with this websocket session). Then we are creating new thread which will be listening for new messages. JLupin channels API is used here. All new messages send through channel are passed to websocket (and being sent to client). Of course at the end thread is saved in context and state is being changed. Handling new messages is very simple also. Received message is sent to all instances of chat-processor microservice (so all connected clients will receive messages). It is done through CHAT_QUEUE queue and handled asynchronously.

When ChatWebSocketHandler is done you must only configure chat microservice to use it. To do so create WebSocketConfigurerImpl class inside com.example.chat.configuration package.

package com.example.chat.configuration;

import com.example.chat.websocket.handler.ChannelWebSocketHandlerImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

@Configuration
@EnableWebSocket
public class WebSocketConfigurerImpl implements WebSocketConfigurer {
    @Autowired
    private ChannelWebSocketHandlerImpl channelWebSocketHandler;

    @Override
    public void registerWebSocketHandlers(final WebSocketHandlerRegistry webSocketHandlerRegistry) {
        webSocketHandlerRegistry.addHandler(
                channelWebSocketHandler, channelWebSocketHandler.getEndpoint()
        ).setAllowedOrigins("*");
    }
}

Nothing special happens in this class. Created handler is bounded to proper endpoint.

But we had used a lot of autowired things. We must define them all. Please update ChatSpringConfiguration class so it look like the one below.

package com.example.chat.configuration;

import com.example.chat.service.interfaces.ChatProcessorService;
import com.example.chat.service.interfaces.UserService;
import com.jlupin.impl.client.util.JLupinClientUtil;
import com.jlupin.impl.client.util.channel.JLupinClientChannelIterableProducer;
import com.jlupin.impl.client.util.publisher.JLupinClientPublisherUtil;
import com.jlupin.interfaces.client.delegator.JLupinDelegator;
import com.jlupin.interfaces.common.enums.PortType;
import com.jlupin.interfaces.microservice.partofjlupin.asynchronous.service.channel.JLupinChannelManagerService;
import com.jlupin.interfaces.microservice.partofjlupin.asynchronous.service.queue.JLupinQueueManagerService;
import com.jlupin.servlet.monitor.annotation.EnableJLupinSpringBootServletMonitor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan("com.example.chat")
@EnableJLupinSpringBootServletMonitor
public class ChatSpringConfiguration {
    @Bean
    public JLupinDelegator getJLupinDelegator() {
        return JLupinClientUtil.generateInnerMicroserviceLoadBalancerDelegator(PortType.JLRMC);
    }

    @Bean
    public JLupinDelegator getQueueJLupinDelegator() {
        return JLupinClientUtil.generateInnerMicroserviceLoadBalancerDelegator(PortType.QUEUE);
    }

    @Bean
    public JLupinQueueManagerService getJLupinQueueManagerService() {
        return JLupinClientUtil.generateRemote(getQueueJLupinDelegator(), "queueMicroservice", "jLupinQueueManagerService", JLupinQueueManagerService.class);
    }

    @Bean
    public JLupinChannelManagerService getJLupinChannelManagerService() {
        return JLupinClientUtil.generateRemote(getJLupinDelegator(), "channelMicroservice", "jLupinChannelManagerService" , JLupinChannelManagerService.class);
    }

    @Bean
    public JLupinClientPublisherUtil getJLupinClientPublisherUtil() {
        return new JLupinClientPublisherUtil("CHAT_QUEUE", getJLupinQueueManagerService());
    }

    @Bean
    public JLupinClientChannelIterableProducer getJLupinClientChannelIterableProducer() {
        return new JLupinClientChannelIterableProducer(getJLupinChannelManagerService());
    }

    @Bean
    public ChatProcessorService getChatProcessorService() {
        return JLupinClientUtil.generateRemote(getJLupinDelegator(), "chat-processor", ChatProcessorService.class);
    }

    @Bean
    public UserService getUserService() {
        return JLupinClientUtil.generateRemote(getJLupinDelegator(), "chat-processor", UserService.class);
    }
}

That's all. For backend part we must do one more thing - we must configure channelMicroservice and queueMicroservice microservices. Let's do it.

Unpack downloaded JLupin Platform. You should see platform directory. Inside there is application directory. Go there. You should see all microservices included with platform. They are used by example application embedded in. You can remove almost all of them, just left: channelMicroservice, queueMicroservice, webcontrol. Then go to channelMicoservice and update channels.yml file. Change 'SAMPLE' to 'CHAT_CHANNEL'. To the same with file queues.yml in queueMicroservice. Change 'SAMPLE' to 'CHAT_QUEUE'. After configuring you can start platform (go to platform/start and run start.sh or start.cmd).

To deploy microservices used maven from command line or create proper run configuration in IntelliJ. Maven command is mvn clean package jlupin-platform:deploy. It will compile code, pack it into deployable units and deploy it to running server.


Part II


Create project

First step will be project creation. From top menu select File -> New -> Project. Then select Empty Activity and click next. Enter name Chat and package com.example.chat. Click finish.

After creating new project wait a few seconds to let Android Studio create all files and index them.


Client development

In this example we will use two additional dependencies. Add them to gradle file.

implementation 'androidx.recyclerview:recyclerview:1.0.0'
implementation 'com.neovisionaries:nv-websocket-client:2.9'

Start with creating objects representing message and sender. Create package model in com.example.chat and add classes shown below.

package com.example.chat.model;

import java.util.Date;

public class Message {
    private final Date createdAt;
    private final String message;
    private final Sender sender;

    public Message(final Date createdAt, final String message, final Sender sender) {
        this.createdAt = createdAt;
        this.message = message;
        this.sender = sender;
    }

    public Date getCreatedAt() {
        return createdAt;
    }

    public String getMessage() {
        return message;
    }

    public Sender getSender() {
        return sender;
    }
}
package com.example.chat.model;

public class Sender {
    private final String username;

    public Sender(String username) {
        this.username = username;
    }

    public String getUsername() {
        return username;
    }
}

Now move to creating views (activities). On first screen user should enter username and channel name.

First screen

Update activity_main.xml file under res/layout as shwown below.

<?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"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_marginLeft="16dp"
    android:layout_marginRight="16dp"
    tools:context=".MainActivity">

<EditText
    android:id="@+id/main_username_edit_text"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:hint="USERNAME"
    app:layout_constraintVertical_chainStyle="packed"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintBottom_toTopOf="@+id/main_channel_name_edit_text"
    android:layout_marginBottom="16dp"
    android:maxLines="1">
</EditText>

<EditText
    android:id="@+id/main_channel_name_edit_text"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    app:layout_constraintTop_toBottomOf="@+id/main_username_edit_text"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintBottom_toTopOf="@+id/main_enter_button"
    android:hint="CHANNEL NAME"
    android:layout_marginBottom="16dp"
    android:maxLines="1">
</EditText>

<Button
    android:id="@+id/main_enter_button"
    android:text="ENTER"
    android:layout_width="0dp"
    android:layout_height="56dp"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/main_channel_name_edit_text"
    app:layout_constraintVertical_chainStyle="packed"/>

</androidx.constraintlayout.widget.ConstraintLayout>

This activity has three elements: two inputs and one button. Each of them has unique id to be available from activity class which is shown below.

package com.example.chat;

import androidx.appcompat.app.AppCompatActivity;

import android.content.Intent;
import android.os.Bundle;
import android.text.Editable;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;

public class MainActivity extends AppCompatActivity {
    private EditText usernameEditText;
    private EditText channelNameEditText;

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

        usernameEditText = findViewById(R.id.main_username_edit_text);
        channelNameEditText = findViewById(R.id.main_channel_name_edit_text);

        final Button sendButton = findViewById(R.id.main_enter_button);
        sendButton.setOnClickListener( new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                final Intent intent = new Intent(getBaseContext(), MessageListActivity.class);
                intent.putExtra(MessageListActivity.LOGGED_USERNAME, getUsername());
                intent.putExtra(MessageListActivity.CHANNEL_NAME, getChannelName());
                startActivity(intent);
            }
        });
    }

    private String getUsername() {
        final Editable text = usernameEditText.getText();

        final String username = text.toString();
        text.clear();

        return username;
    }

    private String getChannelName() {
        final Editable text = channelNameEditText.getText();

        final String channelName = text.toString();
        text.clear();

        return channelName;
    }
}

As you can see when user clicks enter button intent is created. It special object which can be used to transfer data into next view. So username and channel name are set and passed no next activity - MessageListActivity. We will create it now.

Chat view

Next view will contain place to enter message with send button and a list of all messages (sent and received). We will create it from smaller pieces. Start with a circle. It will be user's avatar. Create file cricle.xml under res/drawable as shown below.

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="oval">

<solid
    android:color="#B2DBBF">
</solid>

</shape>

Messages will be shown in rounded rectangle. They should also be created under res/drawable.

rounded_rectangle_blue.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle" >

<!-- View background color -->
<solid
    android:color="#247BA0" >
</solid>

<!-- The radius makes the corners rounded -->
<corners
    android:radius="20dp">
</corners>

</shape>

rounded_rectangle_green.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle" >

<!-- View background color -->
<solid
    android:color="#70C1B3" >
</solid>

<!-- The radius makes the corners rounded -->
<corners
    android:radius="20dp">
</corners>

</shape>

For showing messages we will use recycler view. We need to create layout for recycler view items. Because we have sent and received messages we will create two layouts. Put them into res/layout

item_message_received.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingTop="8dp">

    <ImageView
        android:id="@+id/image_message_profile"
        android:layout_width="32dp"
        android:layout_height="32dp"
        android:layout_marginStart="8dp"
        android:background="@drawable/circle"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/text_message_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="4dp"
        android:textSize="12sp"
        app:layout_constraintLeft_toRightOf="@+id/image_message_profile"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/text_message_body"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginTop="4dp"
        android:background="@drawable/rounded_rectangle_green"
        android:maxWidth="240dp"
        android:padding="8dp"
        android:textColor="#ffffff"
        app:layout_constraintLeft_toRightOf="@+id/image_message_profile"
        app:layout_constraintTop_toBottomOf="@+id/text_message_name" />

    <TextView
        android:id="@+id/text_message_time"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="4dp"
        android:textSize="12sp"
        app:layout_constraintBottom_toBottomOf="@+id/text_message_body"
        app:layout_constraintLeft_toRightOf="@+id/text_message_body" />

</androidx.constraintlayout.widget.ConstraintLayout>

item_message_sent.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingTop="8dp">

    <TextView
        android:id="@+id/text_message_body"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:background="@drawable/rounded_rectangle_blue"
        android:maxWidth="240dp"
        android:padding="8dp"
        android:textColor="#ffffff"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/text_message_time"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="4dp"
        android:textSize="12sp"
        app:layout_constraintBottom_toBottomOf="@+id/text_message_body"
        app:layout_constraintRight_toLeftOf="@+id/text_message_body" />

</androidx.constraintlayout.widget.ConstraintLayout>

This layouts are very similar. Received messages will just have more data. You can see that some elements have background set to elements we have created (circle and rounded rectangles). Also every element has unique id - it will again be used to change this items (like setting message text). Now create layout for activity - activity_message_list.xml.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MessageListActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/reyclerview_message_list"
        app:layout_constraintLeft_toLeftOf="parent"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@+id/message_list_horizontal_line">
    </androidx.recyclerview.widget.RecyclerView>

    <!-- A horizontal line between the chatbox and RecyclerView -->
    <View
        android:id="@+id/message_list_horizontal_line"
        android:layout_width="0dp"
        android:layout_height="2dp"
        android:layout_marginBottom="0dp"
        android:background="#dfdfdf"
        app:layout_constraintBottom_toTopOf="@+id/message_list_new_message_layout"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"/>

    <LinearLayout
        android:id="@+id/message_list_new_message_layout"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:background="#ffffff"
        android:minHeight="48dp"
        android:orientation="horizontal"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent">

        <EditText
            android:id="@+id/message_list_new_message"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:layout_marginLeft="16dp"
            android:layout_marginRight="16dp"
            android:layout_weight="1"
            android:background="@android:color/transparent"
            android:hint="Enter message"
            android:maxLines="6"/>

        <Button
            android:id="@+id/message_list_send"
            android:layout_width="64dp"
            android:layout_height="48dp"
            android:layout_gravity="bottom"
            android:background="?attr/selectableItemBackground"
            android:clickable="true"
            android:gravity="center"
            android:text="SEND"
            android:textSize="14dp" />

    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

It has view for message list and place for sending messages in the bottom. Before we wiil create class for this activity layout we will create adapter for RecyclerView. Create MessageListAdapter class as shown below in package com.example.chat.

package com.example.chat;

import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;

import androidx.recyclerview.widget.RecyclerView;

import com.example.chat.model.Message;
import com.example.chat.util.DateUtil;

import java.util.List;

public class MessageListAdapter extends RecyclerView.Adapter {
    private static final int VIEW_TYPE_MESSAGE_SENT = 1;
    private static final int VIEW_TYPE_MESSAGE_RECEIVED = 2;

    private final Context context;
    private final String loggedUsername;
    private final List messageList;

    public MessageListAdapter(final Context context, final String loggedUsername, final List<Message> messageList) {
        this.context = context;
        this.loggedUsername = loggedUsername;
        this.messageList = messageList;
    }

    @Override
    public int getItemCount() {
        return messageList.size();
    }

    @Override
    public int getItemViewType(int position) {
        final Message message = messageList.get(position);

        if (message.getSender().getUsername().equals(loggedUsername)) {
            return VIEW_TYPE_MESSAGE_SENT;
        } else {
            return VIEW_TYPE_MESSAGE_RECEIVED;
        }
    }

    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(final ViewGroup parent, int viewType) {
        final View view;

        if (viewType == VIEW_TYPE_MESSAGE_SENT) {
            view = LayoutInflater.from(parent.getContext())
                    .inflate(R.layout.item_message_sent, parent, false);
            return new SentMessageHolder(view);
        } else { // viewType == VIEW_TYPE_MESSAGE_RECEIVED
            view = LayoutInflater.from(parent.getContext())
                    .inflate(R.layout.item_message_received, parent, false);
            return new ReceivedMessageHolder(view);
        }
    }

    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
        Message message = messageList.get(position);

        switch (holder.getItemViewType()) {
            case VIEW_TYPE_MESSAGE_SENT:
                ((SentMessageHolder) holder).bind(message);
                break;
            case VIEW_TYPE_MESSAGE_RECEIVED:
                ((ReceivedMessageHolder) holder).bind(message);
        }
    }

    public void addMessage(final Message message) {
        messageList.add(message);
        notifyDataSetChanged();
    }

    private class SentMessageHolder extends RecyclerView.ViewHolder {
        final TextView messageText, timeText;

        SentMessageHolder(final View itemView) {
            super(itemView);

            messageText = itemView.findViewById(R.id.text_message_body);
            timeText = itemView.findViewById(R.id.text_message_time);
        }

        void bind(final Message message) {
            messageText.setText(message.getMessage());
            timeText.setText(DateUtil.formatDate(message.getCreatedAt()));
        }
    }

    private class ReceivedMessageHolder extends RecyclerView.ViewHolder {
        final TextView messageText, timeText, nameText;
        final ImageView profileImage;

        ReceivedMessageHolder(final View itemView) {
            super(itemView);

            messageText = itemView.findViewById(R.id.text_message_body);
            timeText = itemView.findViewById(R.id.text_message_time);
            nameText = itemView.findViewById(R.id.text_message_name);
            profileImage = itemView.findViewById(R.id.image_message_profile);
        }

        void bind(final Message message) {
            messageText.setText(message.getMessage());
            timeText.setText(DateUtil.formatDate(message.getCreatedAt()));
            nameText.setText(message.getSender().getUsername());
        }
    }
}

This adapter is built around the list. Overwritten methods are setting things up. Number of items is taken from list. Then type for item view is calculated by simply comparing sender and logged user name. Then based on view type appropriate holder is created (SentMessageHolder or ReceivedMessageHolder) with one of two layouts we had defined. Both holders are defined at the bottom of class. In their bind method all data for view are set (like message text or date). For rendering date create simple util in com.example.chat.util package as shown below.

package com.example.chat.util;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;

public class DateUtil {
    public static String formatDate(final Date date) {
        final SimpleDateFormat simpleDateFormat = new SimpleDateFormat(
                "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
                Locale.getDefault()
        );
        return simpleDateFormat.format(date);
    }
}

Of course in onBindViewHolder method data are passed to holders to be rendered. Please notice that in addMessage method adapter is notified that data set was changed and then recycler view knows that it should rerender view. Now create ChatService interface (package com.example.chat.service.interfaces) and its implementation class ChatServiceImpl (package com.example.chat.service.impl). It will pack websocket and our protocol handling.

package com.example.chat.service.interfaces;

public interface ChatService {
    void connect(final NewMessageCallback onNewMessage) throws Exception;
    void sendMessage(final String message) throws Exception;

    interface NewMessageCallback {
        void call(final String message) throws Exception;
    }
}
package com.example.chat.service.impl;

import com.example.chat.service.interfaces.ChatService;
import com.neovisionaries.ws.client.WebSocket;
import com.neovisionaries.ws.client.WebSocketAdapter;
import com.neovisionaries.ws.client.WebSocketFactory;

public class ChatServiceImpl implements ChatService {
    private final String channelName;
    private final String username;

    private WebSocket ws;

    public ChatServiceImpl(
            final String channelName, final String username
    ) {
        this.channelName = channelName;
        this.username = username;
    }

    @Override
    public void connect(final NewMessageCallback onNewMessage) throws Exception {
        if (ws != null) {
            return;
        }

        ws = new WebSocketFactory().createSocket("ws://10.0.2.2:8000/chat/channel");
        ws.setPingInterval(1000);

        ws.addListener(new WebSocketAdapter() {
            @Override
            public void onTextMessage(final WebSocket websocket, final String text) throws Exception {
                onNewMessage.call(text);
            }
        });

        ws.connect();
        ws.sendText(username);
        ws.flush();
        ws.sendText(channelName);
        ws.flush();
    }

    public void sendMessage(final String message) throws Exception {
        ws.sendText(message);
        ws.flush();
    }
}

As you can see it has method for sending messages to server and callback for recieved message handler is passed while connecting to server. Now create MessageListActivity.

package com.example.chat;

import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import android.os.AsyncTask;
import android.os.Bundle;
import android.text.Editable;
import android.util.Log;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;

import com.example.chat.model.Message;
import com.example.chat.model.Sender;
import com.example.chat.service.impl.ChatServiceImpl;
import com.example.chat.service.interfaces.ChatService;

import org.json.JSONObject;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

public class MessageListActivity extends AppCompatActivity {
    public static final String LOGGED_USERNAME = "LOGGED_USERNAME";
    public static final String CHANNEL_NAME = "CHANNEL_NAME";

    private RecyclerView messageRecyclerView;
    private MessageListAdapter messageListAdapter;
    private EditText newMessageEditText;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_message_list);

        final List<Message> messageList = new ArrayList<>();

        final String loggedUsername = getIntent().getStringExtra(LOGGED_USERNAME);
        final String channelName = getIntent().getStringExtra(CHANNEL_NAME);

        setTitle(channelName);

        final ChatService chatService = new ChatServiceImpl(channelName, loggedUsername);

        messageRecyclerView = findViewById(R.id.reyclerview_message_list);
        messageRecyclerView.setLayoutManager(new LinearLayoutManager(this));
        messageListAdapter = new MessageListAdapter(this, loggedUsername, messageList);
        messageRecyclerView.setAdapter(messageListAdapter);

        newMessageEditText = findViewById(R.id.message_list_new_message);

        final Button sendButton = findViewById(R.id.message_list_send);

        final ChatService.NewMessageCallback newMessageCallback = message -> {
            final Message m = convertTextMessage(message);
            runOnUiThread(() -> {
                messageListAdapter.addMessage(m);
                messageRecyclerView.scrollToPosition(messageListAdapter.getItemCount() - 1);
            });
        };

        final ConnectChatServiceAsyncTask connectChatServiceAsyncTask =
                new ConnectChatServiceAsyncTask(chatService, newMessageCallback);

        connectChatServiceAsyncTask.execute();
        try {
            final Exception exception = connectChatServiceAsyncTask.get();
            if (exception != null) {
                throw exception;
            }
        } catch (Exception e) {
            Log.e("ChatApplication", "Error receiving message", e);
            Toast.makeText(
                    getApplicationContext(),
                    "Error receiving message",
                    Toast.LENGTH_SHORT
            ).show();
            finish();
        }

        sendButton.setOnClickListener(v -> {
            try {
                String message = getMessageAndClearInput();
                chatService.sendMessage(message);
            } catch (Exception e) {
                Log.e("ChatApplication", "Error sending message", e);
                Toast.makeText(
                        getApplicationContext(),
                        "Error sending message",
                        Toast.LENGTH_SHORT
                ).show();
            }
        });
    }

    private String getMessageAndClearInput() {
        final Editable text = newMessageEditText.getText();
        final String message = text.toString();
        text.clear();

        return message;
    }

    private Message convertTextMessage(final String message) throws Exception {
        final JSONObject jsonObject = new JSONObject(message);

        final long c = jsonObject.getLong("created_at");
        final String m = jsonObject.getString("message");
        final String u = jsonObject.getString("user");

        return new Message(new Date(c), m, new Sender(u));
    }

    private static class ConnectChatServiceAsyncTask extends AsyncTask<Void, Void, Exception> {
        private final ChatService chatService;
        private final ChatService.NewMessageCallback newMessageCallback;

        ConnectChatServiceAsyncTask(
                final ChatService chatService,
                final ChatService.NewMessageCallback newMessageCallback
        ) {
            this.chatService = chatService;
            this.newMessageCallback = newMessageCallback;
        }

        @Override
        protected Exception doInBackground(final Void... voids) {
            try {
                chatService.connect(newMessageCallback);
            } catch (Exception e) {
                return e;
            }

            return null;
        }
    }
}

When view is created passed data (username and channel name) are extracted. New adapter is created and set for recycler view. Also callback for new messages is created. Because you can't make http calls in main thread special class is created for connecting to server called ConnectChatServiceAsyncTask. On main thread we are awaiting result of this operation. If it was unsuccessful we go back to main view and show error notice for user. But if connection was established listener is added to send button and everything is set up.

Now you can create two virtual devices (may be the same type of device) and check if you can enter chat and talk. Remember that name of the user must be one of: peter, thomas, mark. Otherwise you won't log in.


Done tutorial

You can download project which is result of making this tutorial from GitHub: https://github.com/jlupin/chat-android

RATE & DISCUSS (0)

No comments found.