Added channels and more commands.

This commit is contained in:
Andrew Lalis 2021-08-22 12:23:32 +02:00
parent 4faba0d2eb
commit cc5c90fd54
30 changed files with 750 additions and 215 deletions

View File

@ -24,5 +24,4 @@
<version>3.1.1</version> <version>3.1.1</version>
</dependency> </dependency>
</dependencies> </dependencies>
</project> </project>

View File

@ -1,4 +1,5 @@
module concord_client { module concord_client {
requires concord_core; requires concord_core;
requires com.googlecode.lanterna; requires com.googlecode.lanterna;
requires static lombok;
} }

View File

@ -7,11 +7,15 @@ import com.googlecode.lanterna.screen.Screen;
import com.googlecode.lanterna.screen.TerminalScreen; import com.googlecode.lanterna.screen.TerminalScreen;
import com.googlecode.lanterna.terminal.DefaultTerminalFactory; import com.googlecode.lanterna.terminal.DefaultTerminalFactory;
import com.googlecode.lanterna.terminal.Terminal; import com.googlecode.lanterna.terminal.Terminal;
import lombok.Getter;
import lombok.Setter;
import nl.andrewl.concord_client.gui.MainWindow; import nl.andrewl.concord_client.gui.MainWindow;
import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.MessageUtils;
import nl.andrewl.concord_core.msg.Serializer; import nl.andrewl.concord_core.msg.Serializer;
import nl.andrewl.concord_core.msg.types.Chat; import nl.andrewl.concord_core.msg.types.Chat;
import nl.andrewl.concord_core.msg.types.Identification; import nl.andrewl.concord_core.msg.types.Identification;
import nl.andrewl.concord_core.msg.types.ServerMetaData;
import nl.andrewl.concord_core.msg.types.ServerWelcome; import nl.andrewl.concord_core.msg.types.ServerWelcome;
import java.io.DataInputStream; import java.io.DataInputStream;
@ -21,14 +25,20 @@ import java.net.Socket;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.UUID;
public class ConcordClient implements Runnable { public class ConcordClient implements Runnable {
private final Socket socket; private final Socket socket;
private final DataInputStream in; private final DataInputStream in;
private final DataOutputStream out; private final DataOutputStream out;
private final long id; private final UUID id;
private final String nickname; private final String nickname;
@Getter
@Setter
private UUID currentChannelId;
@Getter
private ServerMetaData serverMetaData;
private final Set<ClientMessageListener> messageListeners; private final Set<ClientMessageListener> messageListeners;
private volatile boolean running; private volatile boolean running;
@ -41,6 +51,8 @@ public class ConcordClient implements Runnable {
Message reply = Serializer.readMessage(this.in); Message reply = Serializer.readMessage(this.in);
if (reply instanceof ServerWelcome welcome) { if (reply instanceof ServerWelcome welcome) {
this.id = welcome.getClientId(); this.id = welcome.getClientId();
this.currentChannelId = welcome.getCurrentChannelId();
this.serverMetaData = welcome.getMetaData();
} else { } else {
throw new IOException("Unexpected response from the server after sending identification message."); throw new IOException("Unexpected response from the server after sending identification message.");
} }
@ -55,6 +67,10 @@ public class ConcordClient implements Runnable {
this.messageListeners.remove(listener); this.messageListeners.remove(listener);
} }
public void sendMessage(Message message) throws IOException {
Serializer.writeMessage(message, this.out);
}
public void sendChat(String message) throws IOException { public void sendChat(String message) throws IOException {
Serializer.writeMessage(new Chat(this.id, this.nickname, System.currentTimeMillis(), message), this.out); Serializer.writeMessage(new Chat(this.id, this.nickname, System.currentTimeMillis(), message), this.out);
} }

View File

@ -0,0 +1,58 @@
package nl.andrewl.concord_client.gui;
import com.googlecode.lanterna.TerminalSize;
import com.googlecode.lanterna.gui2.*;
import com.googlecode.lanterna.input.KeyStroke;
import com.googlecode.lanterna.input.KeyType;
import lombok.Getter;
import nl.andrewl.concord_client.ConcordClient;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicBoolean;
public class ChannelChatBox extends Panel {
private final ConcordClient client;
private Border chatBorder;
@Getter
private final ChatList chatList;
private final TextBox inputTextBox;
public ChannelChatBox(ConcordClient client, Window window) {
super(new BorderLayout());
this.client = client;
this.chatList = new ChatList();
this.inputTextBox = new TextBox("", TextBox.Style.MULTI_LINE);
this.inputTextBox.setCaretWarp(true);
this.inputTextBox.setPreferredSize(new TerminalSize(0, 3));
window.addWindowListener(new WindowListenerAdapter() {
@Override
public void onInput(Window basePane, KeyStroke keyStroke, AtomicBoolean deliverEvent) {
if (keyStroke.getKeyType() == KeyType.Enter && inputTextBox.isFocused() && !keyStroke.isShiftDown()) {
String text = inputTextBox.getText();
if (text != null && !text.isBlank()) {
try {
System.out.println("Sending: " + text.trim());
client.sendChat(text.trim());
inputTextBox.setText("");
} catch (IOException e) {
e.printStackTrace();
}
}
deliverEvent.set(false);
}
}
});
this.refreshBorder();
this.addComponent(this.inputTextBox, BorderLayout.Location.BOTTOM);
}
public void refreshBorder() {
String name = client.getServerMetaData().getChannels().stream()
.filter(channelData -> channelData.getId().equals(client.getCurrentChannelId()))
.findAny().orElseThrow().getName();
if (this.chatBorder != null) this.removeComponent(this.chatBorder);
this.chatBorder = Borders.doubleLine("#" + name);
this.chatBorder.setComponent(this.chatList);
this.addComponent(this.chatBorder, BorderLayout.Location.CENTER);
}
}

View File

@ -1,6 +1,38 @@
package nl.andrewl.concord_client.gui; package nl.andrewl.concord_client.gui;
import com.googlecode.lanterna.gui2.AbstractListBox; import com.googlecode.lanterna.gui2.Button;
import com.googlecode.lanterna.gui2.Direction;
import com.googlecode.lanterna.gui2.LinearLayout;
import com.googlecode.lanterna.gui2.Panel;
import nl.andrewl.concord_client.ConcordClient;
import nl.andrewl.concord_core.msg.types.MoveToChannel;
public class ChannelList extends AbstractListBox<String, ChannelList> { import java.io.IOException;
public class ChannelList extends Panel {
private final ConcordClient client;
public ChannelList(ConcordClient client) {
super(new LinearLayout(Direction.VERTICAL));
this.client = client;
}
public void setChannels() {
this.removeAllComponents();
for (var channel : this.client.getServerMetaData().getChannels()) {
String name = channel.getName();
if (client.getCurrentChannelId().equals(channel.getId())) {
name = "*" + name;
}
Button b = new Button(name, () -> {
System.out.println("Sending request to go to channel " + channel.getName());
try {
client.sendMessage(new MoveToChannel(channel.getId()));
} catch (IOException e) {
e.printStackTrace();
}
});
this.addComponent(b, LinearLayout.createLayoutData(LinearLayout.Alignment.End));
}
}
} }

View File

@ -1,16 +1,14 @@
package nl.andrewl.concord_client.gui; package nl.andrewl.concord_client.gui;
import com.googlecode.lanterna.TerminalSize;
import com.googlecode.lanterna.gui2.*; import com.googlecode.lanterna.gui2.*;
import com.googlecode.lanterna.input.KeyStroke; import lombok.Getter;
import com.googlecode.lanterna.input.KeyType;
import nl.andrewl.concord_client.ClientMessageListener; import nl.andrewl.concord_client.ClientMessageListener;
import nl.andrewl.concord_client.ConcordClient; import nl.andrewl.concord_client.ConcordClient;
import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.types.Chat; import nl.andrewl.concord_core.msg.types.Chat;
import nl.andrewl.concord_core.msg.types.MoveToChannel;
import java.io.IOException; import java.io.IOException;
import java.util.concurrent.atomic.AtomicBoolean;
/** /**
* The main panel in which a user interacts with the application during normal * The main panel in which a user interacts with the application during normal
@ -20,8 +18,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
* threads and users in the server. * threads and users in the server.
*/ */
public class ChatPanel extends Panel implements ClientMessageListener { public class ChatPanel extends Panel implements ClientMessageListener {
private final ChatList chatList; @Getter
private final TextBox inputTextBox; private final ChannelChatBox channelChatBox;
private final ChannelList channelList; private final ChannelList channelList;
private final UserList userList; private final UserList userList;
@ -30,41 +28,13 @@ public class ChatPanel extends Panel implements ClientMessageListener {
public ChatPanel(ConcordClient client, Window window) { public ChatPanel(ConcordClient client, Window window) {
super(new BorderLayout()); super(new BorderLayout());
this.client = client; this.client = client;
this.chatList = new ChatList(); this.channelChatBox = new ChannelChatBox(client, window);
this.inputTextBox = new TextBox("", TextBox.Style.MULTI_LINE); this.channelList = new ChannelList(client);
this.inputTextBox.setCaretWarp(true); this.channelList.setChannels();
this.inputTextBox.setPreferredSize(new TerminalSize(0, 3));
this.channelList = new ChannelList();
this.channelList.addItem("general");
this.channelList.addItem("memes");
this.channelList.addItem("testing");
this.userList = new UserList(); this.userList = new UserList();
this.userList.addItem("andrew"); this.userList.addItem("andrew");
this.userList.addItem("tester"); this.userList.addItem("tester");
window.addWindowListener(new WindowListenerAdapter() {
@Override
public void onInput(Window basePane, KeyStroke keyStroke, AtomicBoolean deliverEvent) {
if (keyStroke.getKeyType() == KeyType.Enter) {
if (keyStroke.isShiftDown()) {
System.out.println("Adding newline");
} else {
String text = inputTextBox.getText();
if (text != null && !text.isBlank()) {
try {
System.out.println("Sending: " + text.trim());
client.sendChat(text.trim());
inputTextBox.setText("");
} catch (IOException e) {
e.printStackTrace();
}
}
deliverEvent.set(false);
}
}
}
});
Border b; Border b;
b = Borders.doubleLine("Channels"); b = Borders.doubleLine("Channels");
b.setComponent(this.channelList); b.setComponent(this.channelList);
@ -74,18 +44,18 @@ public class ChatPanel extends Panel implements ClientMessageListener {
b.setComponent(this.userList); b.setComponent(this.userList);
this.addComponent(b, BorderLayout.Location.RIGHT); this.addComponent(b, BorderLayout.Location.RIGHT);
b = Borders.doubleLine("#general"); this.addComponent(this.channelChatBox, BorderLayout.Location.CENTER);
b.setComponent(this.chatList);
this.addComponent(b, BorderLayout.Location.CENTER);
this.addComponent(this.inputTextBox, BorderLayout.Location.BOTTOM);
this.inputTextBox.takeFocus();
} }
@Override @Override
public void messageReceived(ConcordClient client, Message message) throws IOException { public void messageReceived(ConcordClient client, Message message) {
if (message instanceof Chat chat) { if (message instanceof Chat chat) {
this.chatList.addItem(chat); this.channelChatBox.getChatList().addItem(chat);
} else if (message instanceof MoveToChannel moveToChannel) {
client.setCurrentChannelId(moveToChannel.getChannelId());
this.channelList.setChannels();
this.channelChatBox.getChatList().clearItems();
this.channelChatBox.refreshBorder();
} }
} }
} }

View File

@ -55,7 +55,6 @@ public class MainWindow extends BasicWindow {
client.addListener(chatPanel); client.addListener(chatPanel);
new Thread(client).start(); new Thread(client).start();
this.setComponent(chatPanel); this.setComponent(chatPanel);
Borders.joinLinesWithFrame(this.getTextGUI().getScreen().newTextGraphics());
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); e.printStackTrace();
} }

View File

@ -4,6 +4,7 @@ import java.io.DataInputStream;
import java.io.DataOutputStream; import java.io.DataOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.UUID;
/** /**
* Represents any message which can be sent over the network. * Represents any message which can be sent over the network.
@ -35,60 +36,4 @@ public interface Message {
* @throws IOException If an error occurs while reading. * @throws IOException If an error occurs while reading.
*/ */
void read(DataInputStream i) throws IOException; void read(DataInputStream i) throws IOException;
// Utility methods.
/**
* Gets the number of bytes that the given string will occupy when it is
* serialized.
* @param s The string.
* @return The number of bytes used to serialize the string.
*/
default int getByteSize(String s) {
return Integer.BYTES + s.getBytes(StandardCharsets.UTF_8).length;
}
/**
* Writes a string to the given output stream using a length-prefixed format
* where an integer length precedes the string's bytes, which are encoded in
* UTF-8.
* @param s The string to write.
* @param o The output stream to write to.
* @throws IOException If the stream could not be written to.
*/
default void writeString(String s, DataOutputStream o) throws IOException {
if (s == null) {
o.writeInt(-1);
} else {
o.writeInt(s.length());
o.write(s.getBytes(StandardCharsets.UTF_8));
}
}
/**
* Reads a string from the given input stream, using a length-prefixed
* format, where an integer length precedes the string's bytes, which are
* encoded in UTF-8.
* @param i The input stream to read from.
* @return The string which was read.
* @throws IOException If the stream could not be read, or if the string is
* malformed.
*/
default String readString(DataInputStream i) throws IOException {
int length = i.readInt();
if (length == -1) return null;
byte[] data = new byte[length];
int read = i.read(data);
if (read != length) throw new IOException("Not all bytes of a string of length " + length + " could be read.");
return new String(data, StandardCharsets.UTF_8);
}
default void writeEnum(Enum<?> value, DataOutputStream o) throws IOException {
o.writeInt(value.ordinal());
}
default <T extends Enum<?>> T readEnum(Class<T> e, DataInputStream i) throws IOException {
int ordinal = i.readInt();
return e.getEnumConstants()[ordinal];
}
} }

View File

@ -0,0 +1,109 @@
package nl.andrewl.concord_core.msg;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Utility class which provides method for serializing and deserializing complex
* data types.
*/
public class MessageUtils {
public static final int UUID_BYTES = 2 * Long.BYTES;
/**
* Gets the number of bytes that the given string will occupy when it is
* serialized.
* @param s The string.
* @return The number of bytes used to serialize the string.
*/
public static int getByteSize(String s) {
return Integer.BYTES + s.getBytes(StandardCharsets.UTF_8).length;
}
/**
* Writes a string to the given output stream using a length-prefixed format
* where an integer length precedes the string's bytes, which are encoded in
* UTF-8.
* @param s The string to write.
* @param o The output stream to write to.
* @throws IOException If the stream could not be written to.
*/
public static void writeString(String s, DataOutputStream o) throws IOException {
if (s == null) {
o.writeInt(-1);
} else {
o.writeInt(s.length());
o.write(s.getBytes(StandardCharsets.UTF_8));
}
}
/**
* Reads a string from the given input stream, using a length-prefixed
* format, where an integer length precedes the string's bytes, which are
* encoded in UTF-8.
* @param i The input stream to read from.
* @return The string which was read.
* @throws IOException If the stream could not be read, or if the string is
* malformed.
*/
public static String readString(DataInputStream i) throws IOException {
int length = i.readInt();
if (length == -1) return null;
byte[] data = new byte[length];
int read = i.read(data);
if (read != length) throw new IOException("Not all bytes of a string of length " + length + " could be read.");
return new String(data, StandardCharsets.UTF_8);
}
public static void writeEnum(Enum<?> value, DataOutputStream o) throws IOException {
o.writeInt(value.ordinal());
}
public static <T extends Enum<?>> T readEnum(Class<T> e, DataInputStream i) throws IOException {
int ordinal = i.readInt();
return e.getEnumConstants()[ordinal];
}
public static void writeUUID(UUID value, DataOutputStream o) throws IOException {
o.writeLong(value.getMostSignificantBits());
o.writeLong(value.getLeastSignificantBits());
}
public static UUID readUUID(DataInputStream i) throws IOException {
long a = i.readLong();
long b = i.readLong();
return new UUID(a, b);
}
public static int getByteSize(List<? extends Message> items) {
int count = Integer.BYTES;
for (var item : items) {
count += item.getByteCount();
}
return count;
}
public static void writeList(List<? extends Message> items, DataOutputStream o) throws IOException {
o.writeInt(items.size());
for (var i : items) {
i.write(o);
}
}
public static <T extends Message> List<T> readList(Class<T> type, DataInputStream i) throws IOException, ReflectiveOperationException {
int size = i.readInt();
var constructor = type.getConstructor();
List<T> items = new ArrayList<>(size);
for (int k = 0; k < size; k++) {
var item = constructor.newInstance();
item.read(i);
items.add(item);
}
return items;
}
}

View File

@ -1,8 +1,6 @@
package nl.andrewl.concord_core.msg; package nl.andrewl.concord_core.msg;
import nl.andrewl.concord_core.msg.types.Chat; import nl.andrewl.concord_core.msg.types.*;
import nl.andrewl.concord_core.msg.types.Identification;
import nl.andrewl.concord_core.msg.types.ServerWelcome;
import java.io.*; import java.io.*;
import java.util.HashMap; import java.util.HashMap;
@ -18,6 +16,9 @@ public class Serializer {
registerType(0, Identification.class); registerType(0, Identification.class);
registerType(1, ServerWelcome.class); registerType(1, ServerWelcome.class);
registerType(2, Chat.class); registerType(2, Chat.class);
registerType(3, MoveToChannel.class);
registerType(4, ChatHistoryRequest.class);
registerType(5, ChatHistoryResponse.class);
} }
private static void registerType(int id, Class<? extends Message> clazz) { private static void registerType(int id, Class<? extends Message> clazz) {

View File

@ -7,6 +7,9 @@ import nl.andrewl.concord_core.msg.Message;
import java.io.DataInputStream; import java.io.DataInputStream;
import java.io.DataOutputStream; import java.io.DataOutputStream;
import java.io.IOException; import java.io.IOException;
import java.util.UUID;
import static nl.andrewl.concord_core.msg.MessageUtils.*;
/** /**
* This message contains information about a chat message that a user sent. * This message contains information about a chat message that a user sent.
@ -14,12 +17,12 @@ import java.io.IOException;
@Data @Data
@NoArgsConstructor @NoArgsConstructor
public class Chat implements Message { public class Chat implements Message {
private long senderId; private UUID senderId;
private String senderNickname; private String senderNickname;
private long timestamp; private long timestamp;
private String message; private String message;
public Chat(long senderId, String senderNickname, long timestamp, String message) { public Chat(UUID senderId, String senderNickname, long timestamp, String message) {
this.senderId = senderId; this.senderId = senderId;
this.senderNickname = senderNickname; this.senderNickname = senderNickname;
this.timestamp = timestamp; this.timestamp = timestamp;
@ -27,17 +30,17 @@ public class Chat implements Message {
} }
public Chat(String message) { public Chat(String message) {
this(-1, null, System.currentTimeMillis(), message); this(null, null, System.currentTimeMillis(), message);
} }
@Override @Override
public int getByteCount() { public int getByteCount() {
return 2 * Long.BYTES + getByteSize(this.message) + getByteSize(this.senderNickname); return UUID_BYTES + Long.BYTES + getByteSize(this.senderNickname) + getByteSize(this.message);
} }
@Override @Override
public void write(DataOutputStream o) throws IOException { public void write(DataOutputStream o) throws IOException {
o.writeLong(this.senderId); writeUUID(this.senderId, o);
writeString(this.senderNickname, o); writeString(this.senderNickname, o);
o.writeLong(this.timestamp); o.writeLong(this.timestamp);
writeString(this.message, o); writeString(this.message, o);
@ -45,7 +48,7 @@ public class Chat implements Message {
@Override @Override
public void read(DataInputStream i) throws IOException { public void read(DataInputStream i) throws IOException {
this.senderId = i.readLong(); this.senderId = readUUID(i);
this.senderNickname = readString(i); this.senderNickname = readString(i);
this.timestamp = i.readLong(); this.timestamp = i.readLong();
this.message = readString(i); this.message = readString(i);
@ -53,6 +56,6 @@ public class Chat implements Message {
@Override @Override
public String toString() { public String toString() {
return String.format("%s(%d): %s", this.senderNickname, this.senderId, this.message); return String.format("%s: %s", this.senderNickname, this.message);
} }
} }

View File

@ -7,6 +7,9 @@ import nl.andrewl.concord_core.msg.Message;
import java.io.DataInputStream; import java.io.DataInputStream;
import java.io.DataOutputStream; import java.io.DataOutputStream;
import java.io.IOException; import java.io.IOException;
import java.util.UUID;
import static nl.andrewl.concord_core.msg.MessageUtils.*;
/** /**
* A message which clients can send to the server to request some messages from * A message which clients can send to the server to request some messages from
@ -46,25 +49,25 @@ import java.io.IOException;
public class ChatHistoryRequest implements Message { public class ChatHistoryRequest implements Message {
public enum Source {CHANNEL, THREAD, DIRECT_MESSAGE} public enum Source {CHANNEL, THREAD, DIRECT_MESSAGE}
private long sourceId; private UUID sourceId;
private Source sourceType; private Source sourceType;
private String query; private String query;
@Override @Override
public int getByteCount() { public int getByteCount() {
return Long.BYTES + Integer.BYTES + getByteSize(this.query); return UUID_BYTES + Integer.BYTES + getByteSize(this.query);
} }
@Override @Override
public void write(DataOutputStream o) throws IOException { public void write(DataOutputStream o) throws IOException {
o.writeLong(sourceId); writeUUID(this.sourceId, o);
writeEnum(this.sourceType, o); writeEnum(this.sourceType, o);
writeString(this.query, o); writeString(this.query, o);
} }
@Override @Override
public void read(DataInputStream i) throws IOException { public void read(DataInputStream i) throws IOException {
this.sourceId = i.readLong(); this.sourceId = readUUID(i);
this.sourceType = readEnum(Source.class, i); this.sourceType = readEnum(Source.class, i);
this.query = readString(i); this.query = readString(i);
} }

View File

@ -3,19 +3,22 @@ package nl.andrewl.concord_core.msg.types;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.MessageUtils;
import java.io.DataInputStream; import java.io.DataInputStream;
import java.io.DataOutputStream; import java.io.DataOutputStream;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* The response that a server sends to a {@link ChatHistoryRequest}. * The response that a server sends to a {@link ChatHistoryRequest}. The list of
* messages is ordered by timestamp, with the newest messages appearing first.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
public class ChatHistoryResponse implements Message { public class ChatHistoryResponse implements Message {
private long sourceId; private UUID sourceId;
private ChatHistoryRequest.Source sourceType; private ChatHistoryRequest.Source sourceType;
List<Chat> messages; List<Chat> messages;
@ -30,8 +33,8 @@ public class ChatHistoryResponse implements Message {
@Override @Override
public void write(DataOutputStream o) throws IOException { public void write(DataOutputStream o) throws IOException {
o.writeLong(this.sourceId); MessageUtils.writeUUID(this.sourceId, o);
writeEnum(this.sourceType, o); MessageUtils.writeEnum(this.sourceType, o);
o.writeInt(messages.size()); o.writeInt(messages.size());
for (var message : this.messages) { for (var message : this.messages) {
message.write(o); message.write(o);
@ -40,8 +43,8 @@ public class ChatHistoryResponse implements Message {
@Override @Override
public void read(DataInputStream i) throws IOException { public void read(DataInputStream i) throws IOException {
this.sourceId = i.readInt(); this.sourceId = MessageUtils.readUUID(i);
this.sourceType = readEnum(ChatHistoryRequest.Source.class, i); this.sourceType = MessageUtils.readEnum(ChatHistoryRequest.Source.class, i);
int messageCount = i.readInt(); int messageCount = i.readInt();
Chat[] messages = new Chat[messageCount]; Chat[] messages = new Chat[messageCount];
for (int k = 0; k < messageCount; k++) { for (int k = 0; k < messageCount; k++) {

View File

@ -3,6 +3,7 @@ package nl.andrewl.concord_core.msg.types;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.MessageUtils;
import java.io.DataInputStream; import java.io.DataInputStream;
import java.io.DataOutputStream; import java.io.DataOutputStream;
@ -23,16 +24,16 @@ public class Identification implements Message {
@Override @Override
public int getByteCount() { public int getByteCount() {
return getByteSize(this.nickname); return MessageUtils.getByteSize(this.nickname);
} }
@Override @Override
public void write(DataOutputStream o) throws IOException { public void write(DataOutputStream o) throws IOException {
writeString(this.nickname, o); MessageUtils.writeString(this.nickname, o);
} }
@Override @Override
public void read(DataInputStream i) throws IOException { public void read(DataInputStream i) throws IOException {
this.nickname = readString(i); this.nickname = MessageUtils.readString(i);
} }
} }

View File

@ -0,0 +1,46 @@
package nl.andrewl.concord_core.msg.types;
import lombok.Data;
import lombok.NoArgsConstructor;
import nl.andrewl.concord_core.msg.Message;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.UUID;
import static nl.andrewl.concord_core.msg.MessageUtils.*;
/**
* A message that's sent to a client when they've been moved to another channel.
* This indicates to the client that they should perform the necessary requests
* to update their view to indicate that they're now in a different channel.
* <p>
* Conversely, a client can send this request to the server to indicate that
* they would like to switch to the specified channel.
* </p>
*/
@Data
@NoArgsConstructor
public class MoveToChannel implements Message {
private UUID channelId;
public MoveToChannel(UUID channelId) {
this.channelId = channelId;
}
@Override
public int getByteCount() {
return UUID_BYTES;
}
@Override
public void write(DataOutputStream o) throws IOException {
writeUUID(this.channelId, o);
}
@Override
public void read(DataInputStream i) throws IOException {
this.channelId = readUUID(i);
}
}

View File

@ -0,0 +1,68 @@
package nl.andrewl.concord_core.msg.types;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import nl.andrewl.concord_core.msg.Message;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
import static nl.andrewl.concord_core.msg.MessageUtils.*;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ServerMetaData implements Message {
private String name;
private List<ChannelData> channels;
@Override
public int getByteCount() {
return getByteSize(this.name) + getByteSize(this.channels);
}
@Override
public void write(DataOutputStream o) throws IOException {
writeString(this.name, o);
writeList(this.channels, o);
}
@Override
public void read(DataInputStream i) throws IOException {
this.name = readString(i);
try {
this.channels = readList(ChannelData.class, i);
} catch (ReflectiveOperationException e) {
throw new IOException("Reflection exception", e);
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class ChannelData implements Message {
private UUID id;
private String name;
@Override
public int getByteCount() {
return UUID_BYTES + getByteSize(this.name);
}
@Override
public void write(DataOutputStream o) throws IOException {
writeUUID(this.id, o);
writeString(this.name, o);
}
@Override
public void read(DataInputStream i) throws IOException {
this.id = readUUID(i);
this.name = readString(i);
}
}
}

View File

@ -1,32 +1,46 @@
package nl.andrewl.concord_core.msg.types; package nl.andrewl.concord_core.msg.types;
import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor;
import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Message;
import java.io.DataInputStream; import java.io.DataInputStream;
import java.io.DataOutputStream; import java.io.DataOutputStream;
import java.io.IOException; import java.io.IOException;
import java.util.UUID;
import static nl.andrewl.concord_core.msg.MessageUtils.*;
/** /**
* This message is sent from the server to the client after the server accepts * This message is sent from the server to the client after the server accepts
* the client's identification and registers the client in the server. * the client's identification and registers the client in the server.
*/ */
@Data @Data
@NoArgsConstructor
@AllArgsConstructor
public class ServerWelcome implements Message { public class ServerWelcome implements Message {
private long clientId; private UUID clientId;
private UUID currentChannelId;
private ServerMetaData metaData;
@Override @Override
public int getByteCount() { public int getByteCount() {
return Long.BYTES; return 2 * UUID_BYTES + this.metaData.getByteCount();
} }
@Override @Override
public void write(DataOutputStream o) throws IOException { public void write(DataOutputStream o) throws IOException {
o.writeLong(this.clientId); writeUUID(this.clientId, o);
writeUUID(this.currentChannelId, o);
this.metaData.write(o);
} }
@Override @Override
public void read(DataInputStream i) throws IOException { public void read(DataInputStream i) throws IOException {
this.clientId = i.readLong(); this.clientId = readUUID(i);
this.currentChannelId = readUUID(i);
this.metaData = new ServerMetaData();
this.metaData.read(i);
} }
} }

View File

@ -25,23 +25,4 @@
</dependency> </dependency>
</dependencies> </dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project> </project>

View File

@ -0,0 +1,72 @@
package nl.andrewl.concord_server;
import lombok.Getter;
import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.Serializer;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Represents a single communication area in which messages are sent by clients
* and received by all connected clients.
*/
@Getter
public class Channel {
private final ConcordServer server;
private UUID id;
private String name;
private final Set<ClientThread> connectedClients;
public Channel(ConcordServer server, UUID id, String name) {
this.server = server;
this.id = id;
this.name = name;
this.connectedClients = ConcurrentHashMap.newKeySet();
}
public void addClient(ClientThread clientThread) {
this.connectedClients.add(clientThread);
}
public void removeClient(ClientThread clientThread) {
this.connectedClients.remove(clientThread);
}
/**
* Sends a message to all clients that are currently connected to this
* channel.
* @param msg The message to send.
* @throws IOException If an error occurs.
*/
public void sendMessage(Message msg) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream(msg.getByteCount() + 1);
Serializer.writeMessage(msg, baos);
byte[] data = baos.toByteArray();
for (var client : this.connectedClients) {
client.sendToClient(data);
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Channel channel)) return false;
return name.equals(channel.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
@Override
public String toString() {
return this.name;
}
}

View File

@ -0,0 +1,54 @@
package nl.andrewl.concord_server;
import nl.andrewl.concord_core.msg.types.MoveToChannel;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class ChannelManager {
private final ConcordServer server;
private final Map<String, Channel> channelNameMap;
private final Map<UUID, Channel> channelIdMap;
public ChannelManager(ConcordServer server) {
this.server = server;
this.channelNameMap = new ConcurrentHashMap<>();
this.channelIdMap = new ConcurrentHashMap<>();
Channel general = new Channel(server, server.getIdProvider().newId(), "general");
Channel memes = new Channel(server, server.getIdProvider().newId(), "memes");
this.addChannel(general);
this.addChannel(memes);
}
public Set<Channel> getChannels() {
return Set.copyOf(this.channelIdMap.values());
}
public void addChannel(Channel channel) {
this.channelNameMap.put(channel.getName(), channel);
this.channelIdMap.put(channel.getId(), channel);
}
public void removeChannel(Channel channel) {
this.channelNameMap.remove(channel.getName());
this.channelIdMap.remove(channel.getId());
}
public Optional<Channel> getChannelByName(String name) {
return Optional.ofNullable(this.channelNameMap.get(name));
}
public Optional<Channel> getChannelById(UUID id) {
return Optional.ofNullable(this.channelIdMap.get(id));
}
public void moveToChannel(ClientThread client, Channel channel) {
if (client.getCurrentChannel() != null) {
client.getCurrentChannel().removeClient(client);
}
channel.addClient(client);
client.setCurrentChannel(channel);
client.sendToClient(new MoveToChannel(channel.getId()));
System.out.println("Moved client " + client.getClientNickname() + " to channel " + channel.getName());
}
}

View File

@ -0,0 +1,4 @@
package nl.andrewl.concord_server;
public class ChatThread {
}

View File

@ -1,11 +1,10 @@
package nl.andrewl.concord_server; package nl.andrewl.concord_server;
import lombok.Getter; import lombok.Getter;
import lombok.Setter;
import lombok.extern.java.Log; import lombok.extern.java.Log;
import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.Serializer; import nl.andrewl.concord_core.msg.Serializer;
import nl.andrewl.concord_core.msg.types.Chat;
import nl.andrewl.concord_core.msg.types.ChatHistoryRequest;
import nl.andrewl.concord_core.msg.types.Identification; import nl.andrewl.concord_core.msg.types.Identification;
import nl.andrewl.concord_core.msg.types.ServerWelcome; import nl.andrewl.concord_core.msg.types.ServerWelcome;
@ -13,6 +12,7 @@ import java.io.DataInputStream;
import java.io.DataOutputStream; import java.io.DataOutputStream;
import java.io.IOException; import java.io.IOException;
import java.net.Socket; import java.net.Socket;
import java.util.UUID;
/** /**
* This thread is responsible for handling the connection to a single client of * This thread is responsible for handling the connection to a single client of
@ -26,10 +26,16 @@ public class ClientThread extends Thread {
private final ConcordServer server; private final ConcordServer server;
private Long clientId = null; private UUID clientId = null;
@Getter @Getter
private String clientNickname = null; private String clientNickname = null;
@Getter
@Setter
private Channel currentChannel;
private volatile boolean running;
public ClientThread(Socket socket, ConcordServer server) throws IOException { public ClientThread(Socket socket, ConcordServer server) throws IOException {
this.socket = socket; this.socket = socket;
this.server = server; this.server = server;
@ -54,42 +60,60 @@ public class ClientThread extends Thread {
} }
} }
public void shutdown() {
try {
this.socket.close();
} catch (IOException e) {
e.printStackTrace();
}
this.running = false;
}
@Override @Override
public void run() { public void run() {
this.running = true;
if (!identifyClient()) { if (!identifyClient()) {
log.warning("Could not identify the client; aborting connection."); log.warning("Could not identify the client; aborting connection.");
return; this.running = false;
} }
while (true) { while (this.running) {
try { try {
var msg = Serializer.readMessage(this.in); var msg = Serializer.readMessage(this.in);
if (msg instanceof Chat chat) { this.server.getEventManager().handle(msg, this);
this.server.handleChat(chat);
} else if (msg instanceof ChatHistoryRequest historyRequest) {
this.server.handleHistoryRequest(historyRequest, this);
}
} catch (IOException e) { } catch (IOException e) {
log.info("Client disconnected: " + e.getMessage()); log.info("Client disconnected: " + e.getMessage());
if (this.clientId != null) { this.running = false;
this.server.deregisterClient(this.clientId);
}
break;
} }
} }
if (this.clientId != null) {
this.server.deregisterClient(this.clientId);
}
try {
if (!this.socket.isClosed()) {
this.socket.close();
}
} catch (IOException e) {
e.printStackTrace();
}
} }
/**
* Initial method that attempts to obtain identification information from a
* newly-connected client. It is the intent that we should close the socket
* if the client is not able to identify itself.
* @return True if we were able to obtain identification from the client, or
* false otherwise.
*/
private boolean identifyClient() { private boolean identifyClient() {
int attempts = 0; int attempts = 0;
while (attempts < 5) { while (attempts < 5) {
try { try {
var msg = Serializer.readMessage(this.in); var msg = Serializer.readMessage(this.in);
if (msg instanceof Identification id) { if (msg instanceof Identification id) {
this.clientId = this.server.registerClient(this);
this.clientNickname = id.getNickname(); this.clientNickname = id.getNickname();
var reply = new ServerWelcome(); this.clientId = this.server.registerClient(id, this);
reply.setClientId(this.clientId);
Serializer.writeMessage(reply, this.out);
return true; return true;
} }
} catch (IOException e) { } catch (IOException e) {

View File

@ -1,85 +1,100 @@
package nl.andrewl.concord_server; package nl.andrewl.concord_server;
import lombok.Getter;
import lombok.extern.java.Log; import lombok.extern.java.Log;
import nl.andrewl.concord_core.msg.Serializer; import nl.andrewl.concord_core.msg.types.Identification;
import nl.andrewl.concord_core.msg.types.Chat; import nl.andrewl.concord_core.msg.types.ServerMetaData;
import nl.andrewl.concord_core.msg.types.ChatHistoryRequest; import nl.andrewl.concord_core.msg.types.ServerWelcome;
import org.dizitart.no2.Document;
import org.dizitart.no2.Nitrite; import org.dizitart.no2.Nitrite;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException; import java.io.IOException;
import java.net.ServerSocket; import java.net.ServerSocket;
import java.net.Socket; import java.net.Socket;
import java.security.SecureRandom; import java.util.Comparator;
import java.util.Map; import java.util.Map;
import java.util.Random; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
@Log @Log
public class ConcordServer implements Runnable { public class ConcordServer implements Runnable {
private final Map<Long, ClientThread> clients = new ConcurrentHashMap<>(32); private final Map<UUID, ClientThread> clients;
private final int port; private final int port;
private final Random random; @Getter
private final IdProvider idProvider;
@Getter
private final Nitrite db; private final Nitrite db;
private volatile boolean running;
@Getter
private final ExecutorService executorService;
@Getter
private final EventManager eventManager;
@Getter
private final ChannelManager channelManager;
public ConcordServer(int port) { public ConcordServer(int port) {
this.port = port; this.port = port;
this.random = new SecureRandom(); this.idProvider = new UUIDProvider();
this.db = Nitrite.builder() this.db = Nitrite.builder()
.filePath("concord-server.db") .filePath("concord-server.db")
.openOrCreate(); .openOrCreate();
this.clients = new ConcurrentHashMap<>(32);
this.executorService = Executors.newCachedThreadPool();
this.eventManager = new EventManager(this);
this.channelManager = new ChannelManager(this);
} }
public long registerClient(ClientThread clientThread) { /**
long id = this.random.nextLong(); * Registers a new client as connected to the server. This is done once the
log.info("Registering new client " + clientThread.getClientNickname() + " with id " + id); * client thread has received the correct identification information from
* the client. The server will register the client in its global set of
* connected clients, and it will immediately move the client to the default
* channel.
* @param identification The client's identification data.
* @param clientThread The client manager thread.
* @return The id of the client.
*/
public UUID registerClient(Identification identification, ClientThread clientThread) {
var id = this.idProvider.newId();
log.info("Registering new client " + identification.getNickname() + " with id " + id);
this.clients.put(id, clientThread); this.clients.put(id, clientThread);
// Send a welcome reply containing all the initial server info the client needs.
ServerMetaData metaData = new ServerMetaData(
"Testing Server",
this.channelManager.getChannels().stream()
.map(channel -> new ServerMetaData.ChannelData(channel.getId(), channel.getName()))
.sorted(Comparator.comparing(ServerMetaData.ChannelData::getName))
.collect(Collectors.toList())
);
var defaultChannel = this.channelManager.getChannelByName("general").orElseThrow();
defaultChannel.addClient(clientThread);
clientThread.setCurrentChannel(defaultChannel);
clientThread.sendToClient(new ServerWelcome(id, defaultChannel.getId(), metaData));
return id; return id;
} }
public void deregisterClient(long clientId) { public void deregisterClient(UUID clientId) {
this.clients.remove(clientId); var client = this.clients.remove(clientId);
} if (client != null) {
client.getCurrentChannel().removeClient(client);
public void handleChat(Chat chat) { client.shutdown();
var collection = db.getCollection("channel-TEST");
long messageId = this.random.nextLong();
Document doc = Document.createDocument(Long.toHexString(messageId), "message")
.put("senderId", Long.toHexString(chat.getSenderId()))
.put("senderNickname", chat.getSenderNickname())
.put("timestamp", chat.getTimestamp())
.put("message", chat.getMessage());
collection.insert(doc);
db.commit();
System.out.println(chat.getSenderNickname() + ": " + chat.getMessage());
ByteArrayOutputStream baos = new ByteArrayOutputStream(chat.getByteCount());
try {
Serializer.writeMessage(chat, new DataOutputStream(baos));
} catch (IOException e) {
e.printStackTrace();
return;
} }
byte[] data = baos.toByteArray();
for (var client : clients.values()) {
client.sendToClient(data);
}
}
public void handleHistoryRequest(ChatHistoryRequest request, ClientThread clientThread) {
} }
@Override @Override
public void run() { public void run() {
this.running = true;
ServerSocket serverSocket; ServerSocket serverSocket;
try { try {
serverSocket = new ServerSocket(this.port); serverSocket = new ServerSocket(this.port);
log.info("Opened server on port " + this.port); log.info("Opened server on port " + this.port);
while (true) { while (this.running) {
Socket socket = serverSocket.accept(); Socket socket = serverSocket.accept();
log.info("Accepted new socket connection."); log.info("Accepted new socket connection from " + socket.getInetAddress().getHostAddress());
ClientThread clientThread = new ClientThread(socket, this); ClientThread clientThread = new ClientThread(socket, this);
clientThread.start(); clientThread.start();
} }

View File

@ -0,0 +1,37 @@
package nl.andrewl.concord_server;
import lombok.extern.java.Log;
import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.types.Chat;
import nl.andrewl.concord_core.msg.types.MoveToChannel;
import nl.andrewl.concord_server.event.ChannelMoveHandler;
import nl.andrewl.concord_server.event.ChatHandler;
import nl.andrewl.concord_server.event.MessageHandler;
import java.util.HashMap;
import java.util.Map;
@Log
public class EventManager {
private final Map<Class<? extends Message>, MessageHandler<?>> messageHandlers;
private final ConcordServer server;
public EventManager(ConcordServer server) {
this.server = server;
this.messageHandlers = new HashMap<>();
this.messageHandlers.put(Chat.class, new ChatHandler());
this.messageHandlers.put(MoveToChannel.class, new ChannelMoveHandler());
}
@SuppressWarnings("unchecked")
public <T extends Message> void handle(T message, ClientThread client) {
MessageHandler<T> handler = (MessageHandler<T>) this.messageHandlers.get(message.getClass());
if (handler != null) {
try {
handler.handle(message, client, this.server);
} catch (Exception e) {
log.warning("Exception occurred while handling message: " + e.getMessage());
}
}
}
}

View File

@ -0,0 +1,7 @@
package nl.andrewl.concord_server;
import java.util.UUID;
public interface IdProvider {
UUID newId();
}

View File

@ -0,0 +1,10 @@
package nl.andrewl.concord_server;
import java.util.UUID;
public class UUIDProvider implements IdProvider {
@Override
public UUID newId() {
return UUID.randomUUID();
}
}

View File

@ -0,0 +1,16 @@
package nl.andrewl.concord_server.event;
import nl.andrewl.concord_core.msg.types.MoveToChannel;
import nl.andrewl.concord_server.ClientThread;
import nl.andrewl.concord_server.ConcordServer;
/**
* Handles client requests to move to another channel.
*/
public class ChannelMoveHandler implements MessageHandler<MoveToChannel> {
@Override
public void handle(MoveToChannel msg, ClientThread client, ConcordServer server) {
var optionalChannel = server.getChannelManager().getChannelById(msg.getChannelId());
optionalChannel.ifPresent(channel -> server.getChannelManager().moveToChannel(client, channel));
}
}

View File

@ -0,0 +1,29 @@
package nl.andrewl.concord_server.event;
import nl.andrewl.concord_core.msg.types.Chat;
import nl.andrewl.concord_server.ClientThread;
import nl.andrewl.concord_server.ConcordServer;
import org.dizitart.no2.Document;
import java.io.IOException;
import java.util.Map;
public class ChatHandler implements MessageHandler<Chat> {
@Override
public void handle(Chat msg, ClientThread client, ConcordServer server) throws IOException {
server.getExecutorService().submit(() -> {
var collection = server.getDb().getCollection("channel-" + client.getCurrentChannel().getId());
var messageId = server.getIdProvider().newId();
Document doc = new Document(Map.of(
"_id", messageId,
"senderId", msg.getSenderId(),
"senderNickname", msg.getSenderNickname(),
"timestamp", msg.getTimestamp(),
"message", msg.getMessage()
));
collection.insert(doc);
});
System.out.printf("#%s | %s: %s\n", client.getCurrentChannel(), client.getClientNickname(), msg.getMessage());
client.getCurrentChannel().sendMessage(msg);
}
}

View File

@ -0,0 +1,9 @@
package nl.andrewl.concord_server.event;
import nl.andrewl.concord_core.msg.types.Chat;
import nl.andrewl.concord_server.ClientThread;
import nl.andrewl.concord_server.ConcordServer;
public interface EventListener {
default void chatMessageReceived(ConcordServer server, Chat chat, ClientThread client) {}
}

View File

@ -0,0 +1,9 @@
package nl.andrewl.concord_server.event;
import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_server.ClientThread;
import nl.andrewl.concord_server.ConcordServer;
public interface MessageHandler<T extends Message> {
void handle(T msg, ClientThread client, ConcordServer server) throws Exception;
}