Refactored core message structure to use records.

This commit is contained in:
Andrew Lalis 2021-09-24 19:37:57 +02:00
parent cd7d40cd7d
commit 631ded2afb
51 changed files with 705 additions and 847 deletions

View File

@ -20,6 +20,12 @@ import nl.andrewl.concord_core.msg.Encryption;
import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.Serializer;
import nl.andrewl.concord_core.msg.types.*;
import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
import nl.andrewl.concord_core.msg.types.chat.Chat;
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryRequest;
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryResponse;
import nl.andrewl.concord_core.msg.types.client_setup.Identification;
import nl.andrewl.concord_core.msg.types.client_setup.ServerWelcome;
import java.io.IOException;
import java.io.InputStream;
@ -84,8 +90,8 @@ public class ConcordClient implements Runnable {
this.serializer.writeMessage(new Identification(nickname, token), this.out);
Message reply = this.serializer.readMessage(this.in);
if (reply instanceof ServerWelcome welcome) {
var model = new ClientModel(welcome.getClientId(), nickname, welcome.getCurrentChannelId(), welcome.getCurrentChannelName(), welcome.getMetaData());
this.saveSessionToken(welcome.getSessionToken(), tokensFile);
var model = new ClientModel(welcome.clientId(), nickname, welcome.currentChannelId(), welcome.currentChannelName(), welcome.metaData());
this.saveSessionToken(welcome.sessionToken(), tokensFile);
// Start fetching initial data for the channel we were initially put into.
this.sendMessage(new ChatHistoryRequest(model.getCurrentChannelId(), ""));
return model;

View File

@ -1,7 +1,7 @@
package nl.andrewl.concord_client.event;
import nl.andrewl.concord_client.model.ChatHistory;
import nl.andrewl.concord_core.msg.types.Chat;
import nl.andrewl.concord_core.msg.types.chat.Chat;
public interface ChatHistoryListener {
default void chatAdded(Chat chat) {}

View File

@ -2,10 +2,8 @@ package nl.andrewl.concord_client.event.handlers;
import nl.andrewl.concord_client.ConcordClient;
import nl.andrewl.concord_client.event.MessageHandler;
import nl.andrewl.concord_core.msg.types.ChatHistoryRequest;
import nl.andrewl.concord_core.msg.types.MoveToChannel;
import java.util.Map;
import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryRequest;
/**
* When the client receives a {@link MoveToChannel} message, it means that the
@ -16,7 +14,7 @@ import java.util.Map;
public class ChannelMovedHandler implements MessageHandler<MoveToChannel> {
@Override
public void handle(MoveToChannel msg, ConcordClient client) throws Exception {
client.getModel().setCurrentChannel(msg.getId(), msg.getChannelName());
client.sendMessage(new ChatHistoryRequest(msg.getId()));
client.getModel().setCurrentChannel(msg.id(), msg.channelName());
client.sendMessage(new ChatHistoryRequest(msg.id()));
}
}

View File

@ -2,11 +2,14 @@ package nl.andrewl.concord_client.event.handlers;
import nl.andrewl.concord_client.ConcordClient;
import nl.andrewl.concord_client.event.MessageHandler;
import nl.andrewl.concord_core.msg.types.ChatHistoryResponse;
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryResponse;
import java.util.Arrays;
import java.util.List;
public class ChatHistoryResponseHandler implements MessageHandler<ChatHistoryResponse> {
@Override
public void handle(ChatHistoryResponse msg, ConcordClient client) {
client.getModel().getChatHistory().setChats(msg.getMessages());
client.getModel().getChatHistory().setChats(Arrays.asList(msg.messages()));
}
}

View File

@ -4,9 +4,11 @@ import nl.andrewl.concord_client.ConcordClient;
import nl.andrewl.concord_client.event.MessageHandler;
import nl.andrewl.concord_core.msg.types.ServerUsers;
import java.util.Arrays;
public class ServerUsersHandler implements MessageHandler<ServerUsers> {
@Override
public void handle(ServerUsers msg, ConcordClient client) {
client.getModel().setKnownUsers(msg.getUsers());
client.getModel().setKnownUsers(Arrays.asList(msg.users()));
}
}

View File

@ -5,7 +5,7 @@ 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;
import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
import java.io.IOException;
@ -22,15 +22,15 @@ public class ChannelList extends Panel {
public void setChannels() {
this.removeAllComponents();
for (var channel : this.client.getModel().getServerMetaData().getChannels()) {
String name = channel.getName();
if (client.getModel().getCurrentChannelId().equals(channel.getId())) {
for (var channel : this.client.getModel().getServerMetaData().channels()) {
String name = channel.name();
if (client.getModel().getCurrentChannelId().equals(channel.id())) {
name = "*" + name;
}
Button b = new Button(name, () -> {
if (!client.getModel().getCurrentChannelId().equals(channel.getId())) {
if (!client.getModel().getCurrentChannelId().equals(channel.id())) {
try {
client.sendMessage(new MoveToChannel(channel.getId()));
client.sendMessage(new MoveToChannel(channel.id()));
} catch (IOException e) {
e.printStackTrace();
}

View File

@ -3,7 +3,7 @@ package nl.andrewl.concord_client.gui;
import com.googlecode.lanterna.gui2.AbstractListBox;
import nl.andrewl.concord_client.event.ChatHistoryListener;
import nl.andrewl.concord_client.model.ChatHistory;
import nl.andrewl.concord_core.msg.types.Chat;
import nl.andrewl.concord_core.msg.types.chat.Chat;
/**
* This chat list shows a section of chat messages that have been sent in a

View File

@ -4,7 +4,7 @@ import com.googlecode.lanterna.TerminalTextUtils;
import com.googlecode.lanterna.graphics.ThemeDefinition;
import com.googlecode.lanterna.gui2.AbstractListBox;
import com.googlecode.lanterna.gui2.TextGUIGraphics;
import nl.andrewl.concord_core.msg.types.Chat;
import nl.andrewl.concord_core.msg.types.chat.Chat;
import java.time.Instant;
import java.time.ZoneId;
@ -20,10 +20,10 @@ public class ChatRenderer extends AbstractListBox.ListItemRenderer<Chat, ChatLis
else {
graphics.applyThemeStyle(themeDefinition.getNormal());
}
graphics.putString(0, 0, chat.getSenderNickname());
Instant timestamp = Instant.ofEpochMilli(chat.getTimestamp());
graphics.putString(0, 0, chat.senderNickname());
Instant timestamp = Instant.ofEpochMilli(chat.timestamp());
String timeStr = timestamp.atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("HH:mm"));
String label = chat.getSenderNickname() + "@" + timeStr + " : " + chat.getMessage();
String label = chat.senderNickname() + "@" + timeStr + " : " + chat.message();
label = TerminalTextUtils.fitString(label, graphics.getSize().getColumns());
while(TerminalTextUtils.getColumnWidth(label) < graphics.getSize().getColumns()) {
label += " ";

View File

@ -5,7 +5,7 @@ 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;
import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
import nl.andrewl.concord_core.msg.types.UserData;
import java.io.IOException;
@ -22,11 +22,11 @@ public class UserList extends Panel {
public void updateUsers(List<UserData> usersResponse) {
this.removeAllComponents();
for (var user : usersResponse) {
Button b = new Button(user.getName(), () -> {
if (!client.getModel().getId().equals(user.getId())) {
System.out.println("Opening DM channel with user " + user.getName() + ", id: " + user.getId());
Button b = new Button(user.name(), () -> {
if (!client.getModel().getId().equals(user.id())) {
System.out.println("Opening DM channel with user " + user.name() + ", id: " + user.id());
try {
client.sendMessage(new MoveToChannel(user.getId()));
client.sendMessage(new MoveToChannel(user.id()));
} catch (IOException e) {
e.printStackTrace();
}

View File

@ -2,7 +2,7 @@ package nl.andrewl.concord_client.model;
import lombok.Getter;
import nl.andrewl.concord_client.event.ChatHistoryListener;
import nl.andrewl.concord_core.msg.types.Chat;
import nl.andrewl.concord_core.msg.types.chat.Chat;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

View File

@ -3,5 +3,9 @@ module concord_core {
exports nl.andrewl.concord_core.util to concord_server, concord_client;
exports nl.andrewl.concord_core.msg to concord_server, concord_client;
exports nl.andrewl.concord_core.msg.types to concord_server, concord_client;
exports nl.andrewl.concord_core.msg.types.client_setup to concord_client, concord_server;
exports nl.andrewl.concord_core.msg.types.chat to concord_client, concord_server;
exports nl.andrewl.concord_core.msg.types.channel to concord_client, concord_server;
}

View File

@ -1,6 +1,6 @@
package nl.andrewl.concord_core.msg;
import nl.andrewl.concord_core.msg.types.KeyData;
import nl.andrewl.concord_core.msg.types.client_setup.KeyData;
import nl.andrewl.concord_core.util.Pair;
import javax.crypto.Cipher;
@ -64,20 +64,20 @@ public class Encryption {
// Receive and decode client's unencrypted key data.
KeyData clientKeyData = (KeyData) serializer.readMessage(in);
PublicKey clientPublicKey = KeyFactory.getInstance("EC")
.generatePublic(new X509EncodedKeySpec(clientKeyData.getPublicKey()));
.generatePublic(new X509EncodedKeySpec(clientKeyData.publicKey()));
// Compute secret key from client's public key and our private key.
KeyAgreement ka = KeyAgreement.getInstance("ECDH");
ka.init(keyPair.getPrivate());
ka.doPhase(clientPublicKey, true);
byte[] secretKey = computeSecretKey(ka.generateSecret(), publicKey, clientKeyData.getPublicKey());
byte[] secretKey = computeSecretKey(ka.generateSecret(), publicKey, clientKeyData.publicKey());
// Initialize cipher streams.
Cipher writeCipher = Cipher.getInstance("AES/CFB8/NoPadding");
Cipher readCipher = Cipher.getInstance("AES/CFB8/NoPadding");
Key cipherKey = new SecretKeySpec(secretKey, "AES");
writeCipher.init(Cipher.ENCRYPT_MODE, cipherKey, new IvParameterSpec(iv));
readCipher.init(Cipher.DECRYPT_MODE, cipherKey, new IvParameterSpec(clientKeyData.getIv()));
readCipher.init(Cipher.DECRYPT_MODE, cipherKey, new IvParameterSpec(clientKeyData.iv()));
return new Pair<>(
new CipherInputStream(in, readCipher),
new CipherOutputStream(out, writeCipher)

View File

@ -1,11 +1,5 @@
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.UUID;
/**
* Represents any message which can be sent over the network.
* <p>
@ -14,26 +8,12 @@ import java.util.UUID;
* </p>
*/
public interface Message {
/**
* @return The exact number of bytes that this message will use when written
* to a stream.
*/
int getByteCount();
/**
* Writes this message to the given output stream.
* @param o The output stream to write to.
* @throws IOException If an error occurs while writing.
*/
void write(DataOutputStream o) throws IOException;
/**
* Reads all of this message's properties from the given input stream.
* <p>
* The single byte type identifier has already been read.
* </p>
* @param i The input stream to read from.
* @throws IOException If an error occurs while reading.
*/
void read(DataInputStream i) throws IOException;
@SuppressWarnings("unchecked")
default <T extends Message> MessageType<T> getType() {
return MessageType.get((Class<T>) this.getClass());
}
default int byteSize() {
return getType().byteSizeFunction().apply(this);
}
}

View File

@ -0,0 +1,19 @@
package nl.andrewl.concord_core.msg;
import nl.andrewl.concord_core.util.ExtendedDataInputStream;
import java.io.IOException;
@FunctionalInterface
public interface MessageReader<T extends Message>{
/**
* Reads all of this message's properties from the given input stream.
* <p>
* The single byte type identifier has already been read.
* </p>
* @param in The input stream to read from.
* @return The message that was read.
* @throws IOException If an error occurs while reading.
*/
T read(ExtendedDataInputStream in) throws IOException;
}

View File

@ -0,0 +1,130 @@
package nl.andrewl.concord_core.msg;
import java.lang.reflect.Constructor;
import java.lang.reflect.RecordComponent;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
/**
* Record containing the components needed to read and write a given message.
* <p>
* Also contains methods for automatically generating message type
* implementations for standard record-based messages.
* </p>
* @param <T> The type of message.
* @param messageClass The class of the message.
* @param byteSizeFunction A function that computes the byte size of the message.
* @param reader A reader that can read messages from an input stream.
* @param writer A writer that write messages from an input stream.
*/
public record MessageType<T extends Message>(
Class<T> messageClass,
Function<T, Integer> byteSizeFunction,
MessageReader<T> reader,
MessageWriter<T> writer
) {
private static final Map<Class<?>, MessageType<?>> generatedMessageTypes = new HashMap<>();
/**
* Gets the {@link MessageType} instance for a given message class, and
* generates a new implementation if none exists yet.
* @param messageClass The class of the message to get a type for.
* @param <T> The type of the message.
* @return The message type.
*/
@SuppressWarnings("unchecked")
public static <T extends Message> MessageType<T> get(Class<T> messageClass) {
return (MessageType<T>) generatedMessageTypes.computeIfAbsent(messageClass, c -> generateForRecord((Class<T>) c));
}
/**
* Generates a message type instance for a given class, using reflection to
* introspect the fields of the message.
* <p>
* Note that this only works for record-based messages.
* </p>
* @param messageTypeClass The class of the message type.
* @param <T> The type of the message.
* @return A message type instance.
*/
public static <T extends Message> MessageType<T> generateForRecord(Class<T> messageTypeClass) {
RecordComponent[] components = messageTypeClass.getRecordComponents();
Constructor<T> constructor;
try {
constructor = messageTypeClass.getDeclaredConstructor(Arrays.stream(components)
.map(RecordComponent::getType).toArray(Class<?>[]::new));
} catch (NoSuchMethodException e) {
throw new IllegalArgumentException(e);
}
return new MessageType<>(
messageTypeClass,
generateByteSizeFunction(components),
generateReader(constructor),
generateWriter(components)
);
}
/**
* Generates a function implementation that counts the byte size of a
* message based on the message's record component types.
* @param components The list of components that make up the message.
* @param <T> The message type.
* @return A function that computes the byte size of a message of the given
* type.
*/
private static <T extends Message> Function<T, Integer> generateByteSizeFunction(RecordComponent[] components) {
return msg -> {
int size = 0;
for (var component : components) {
try {
size += MessageUtils.getByteSize(component.getAccessor().invoke(msg));
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
}
return size;
};
}
/**
* Generates a message reader for the given message constructor method. It
* will try to read objects from the input stream according to the
* parameters of the canonical constructor of a message record.
* @param constructor The canonical constructor of the message record.
* @param <T> The message type.
* @return A message reader for the given type.
*/
private static <T extends Message> MessageReader<T> generateReader(Constructor<T> constructor) {
return in -> {
Object[] values = new Object[constructor.getParameterCount()];
for (int i = 0; i < values.length; i++) {
values[i] = in.readObject(constructor.getParameterTypes()[i]);
}
try {
return constructor.newInstance(values);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
};
}
/**
* Generates a message writer for the given message record components.
* @param components The record components to write.
* @param <T> The type of message.
* @return The message writer for the given type.
*/
private static <T extends Message> MessageWriter<T> generateWriter(RecordComponent[] components) {
return (msg, out) -> {
for (var component: components) {
try {
out.writeObject(component.getAccessor().invoke(msg), component.getType());
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
}
};
}
}

View File

@ -1,11 +1,6 @@
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;
/**
@ -14,6 +9,7 @@ import java.util.UUID;
*/
public class MessageUtils {
public static final int UUID_BYTES = 2 * Long.BYTES;
public static final int ENUM_BYTES = Integer.BYTES;
/**
* Gets the number of bytes that the given string will occupy when it is
@ -26,118 +22,54 @@ public class MessageUtils {
}
/**
* 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.
* Gets the number of bytes that all the given strings will occupy when
* serialized with a length-prefix encoding.
* @param strings The set of strings.
* @return The total byte size.
*/
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));
public static int getByteSize(String... strings) {
int size = 0;
for (var s : strings) {
size += getByteSize(s);
}
return size;
}
/**
* 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;
if (length == 0) return "";
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);
}
/**
* Writes an enum value to the given stream as the integer ordinal value of
* the enum value, or -1 if the value is null.
* @param value The value to write.
* @param o The output stream.
* @throws IOException If an error occurs while writing.
*/
public static void writeEnum(Enum<?> value, DataOutputStream o) throws IOException {
if (value == null) {
o.writeInt(-1);
} else {
o.writeInt(value.ordinal());
}
}
/**
* Reads an enum value from the given stream, assuming that the value is
* represented by an integer ordinal value.
* @param e The type of enum that is to be read.
* @param i The input stream to read from.
* @param <T> The enum type.
* @return The enum value, or null if -1 was read.
* @throws IOException If an error occurs while reading.
*/
public static <T extends Enum<?>> T readEnum(Class<T> e, DataInputStream i) throws IOException {
int ordinal = i.readInt();
if (ordinal == -1) return null;
return e.getEnumConstants()[ordinal];
}
public static void writeUUID(UUID value, DataOutputStream o) throws IOException {
if (value == null) {
o.writeLong(-1);
o.writeLong(-1);
} else {
o.writeLong(value.getMostSignificantBits());
o.writeLong(value.getLeastSignificantBits());
}
}
public static UUID readUUID(DataInputStream i) throws IOException {
long a = i.readLong();
long b = i.readLong();
if (a == -1 && b == -1) {
return null;
}
return new UUID(a, b);
}
public static int getByteSize(List<? extends Message> items) {
public static <T extends Message> int getByteSize(T[] items) {
int count = Integer.BYTES;
for (var item : items) {
count += item.getByteCount();
count += item.byteSize();
}
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 int getByteSize(Object o) {
if (o instanceof Integer) {
return Integer.BYTES;
} else if (o instanceof Long) {
return Long.BYTES;
} else if (o instanceof String) {
return getByteSize((String) o);
} else if (o instanceof UUID) {
return UUID_BYTES;
} else if (o instanceof Enum<?>) {
return ENUM_BYTES;
} else if (o instanceof byte[]) {
return Integer.BYTES + ((byte[]) o).length;
} else if (o.getClass().isArray() && Message.class.isAssignableFrom(o.getClass().getComponentType())) {
return getByteSize((Message[]) o);
} else if (o instanceof Message) {
return ((Message) o).byteSize();
} else {
throw new IllegalArgumentException("Unsupported object type: " + o.getClass().getSimpleName());
}
}
public static <T extends Message> List<T> readList(Class<T> type, DataInputStream i) throws IOException {
int size = i.readInt();
try {
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;
} catch (ReflectiveOperationException e) {
throw new IOException(e);
public static int getByteSize(Object... objects) {
int size = 0;
for (var o : objects) {
size += getByteSize(o);
}
return size;
}
}

View File

@ -0,0 +1,16 @@
package nl.andrewl.concord_core.msg;
import nl.andrewl.concord_core.util.ChainedDataOutputStream;
import java.io.IOException;
@FunctionalInterface
public interface MessageWriter<T extends Message> {
/**
* Writes this message to the given output stream.
* @param msg The message to write.
* @param out The output stream to write to.
* @throws IOException If an error occurs while writing.
*/
void write(T msg, ChainedDataOutputStream out) throws IOException;
}

View File

@ -1,9 +1,24 @@
package nl.andrewl.concord_core.msg;
import nl.andrewl.concord_core.msg.types.Error;
import nl.andrewl.concord_core.msg.types.*;
import nl.andrewl.concord_core.msg.types.ServerMetaData;
import nl.andrewl.concord_core.msg.types.ServerUsers;
import nl.andrewl.concord_core.msg.types.channel.CreateThread;
import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
import nl.andrewl.concord_core.msg.types.chat.Chat;
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryRequest;
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryResponse;
import nl.andrewl.concord_core.msg.types.client_setup.Identification;
import nl.andrewl.concord_core.msg.types.client_setup.KeyData;
import nl.andrewl.concord_core.msg.types.client_setup.Registration;
import nl.andrewl.concord_core.msg.types.client_setup.ServerWelcome;
import nl.andrewl.concord_core.util.ChainedDataOutputStream;
import nl.andrewl.concord_core.util.ExtendedDataInputStream;
import java.io.*;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;
@ -17,13 +32,13 @@ public class Serializer {
* The mapping which defines each supported message type and the byte value
* used to identify it when reading and writing messages.
*/
private final Map<Byte, Class<? extends Message>> messageTypes = new HashMap<>();
private final Map<Byte, MessageType<?>> messageTypes = new HashMap<>();
/**
* An inverse of {@link Serializer#messageTypes} which is used to look up a
* message's byte value when you know the class of the message.
*/
private final Map<Class<? extends Message>, Byte> inverseMessageTypes = new HashMap<>();
private final Map<MessageType<?>, Byte> inverseMessageTypes = new HashMap<>();
/**
* Constructs a new serializer instance, with a standard set of supported
@ -36,7 +51,7 @@ public class Serializer {
registerType(3, MoveToChannel.class);
registerType(4, ChatHistoryRequest.class);
registerType(5, ChatHistoryResponse.class);
// Type id 6 removed due to deprecation.
registerType(6, Registration.class);
registerType(7, ServerUsers.class);
registerType(8, ServerMetaData.class);
registerType(9, Error.class);
@ -49,12 +64,12 @@ public class Serializer {
* serializer, by adding it to the normal and inverse mappings.
* @param id The byte which will be used to identify messages of the given
* class. The value should from 0 to 127.
* @param messageClass The class of message which is registered with the
* given byte identifier.
* @param messageClass The type of message associated with the given id.
*/
private synchronized void registerType(int id, Class<? extends Message> messageClass) {
messageTypes.put((byte) id, messageClass);
inverseMessageTypes.put(messageClass, (byte) id);
private synchronized <T extends Message> void registerType(int id, Class<T> messageClass) {
MessageType<T> type = MessageType.get(messageClass);
messageTypes.put((byte) id, type);
inverseMessageTypes.put(type, (byte) id);
}
/**
@ -67,19 +82,16 @@ public class Serializer {
* constructed for the incoming data.
*/
public Message readMessage(InputStream i) throws IOException {
DataInputStream d = new DataInputStream(i);
byte type = d.readByte();
var clazz = messageTypes.get(type);
if (clazz == null) {
throw new IOException("Unsupported message type: " + type);
ExtendedDataInputStream d = new ExtendedDataInputStream(i);
byte typeId = d.readByte();
var type = messageTypes.get(typeId);
if (type == null) {
throw new IOException("Unsupported message type: " + typeId);
}
try {
var constructor = clazz.getConstructor();
var message = constructor.newInstance();
message.read(d);
return message;
return type.reader().read(d);
} catch (Throwable e) {
throw new IOException("Could not instantiate new message object of type " + clazz.getSimpleName(), e);
throw new IOException("Could not instantiate new message object of type " + type.getClass().getSimpleName(), e);
}
}
@ -90,14 +102,14 @@ public class Serializer {
* @throws IOException If an error occurs while writing, or if the message
* to write is not supported by this serializer.
*/
public void writeMessage(Message msg, OutputStream o) throws IOException {
public <T extends Message> void writeMessage(Message msg, OutputStream o) throws IOException {
DataOutputStream d = new DataOutputStream(o);
Byte type = inverseMessageTypes.get(msg.getClass());
if (type == null) {
Byte typeId = inverseMessageTypes.get(msg.getType());
if (typeId == null) {
throw new IOException("Unsupported message type: " + msg.getClass().getSimpleName());
}
d.writeByte(type);
msg.write(d);
d.writeByte(typeId);
msg.getType().writer().write(msg, new ChainedDataOutputStream(d));
d.flush();
}
}

View File

@ -1,83 +0,0 @@
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.Objects;
import java.util.UUID;
import static nl.andrewl.concord_core.msg.MessageUtils.*;
/**
* This message contains information about a chat message that a user sent.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Chat implements Message {
private static final long ID_NONE = 0;
private UUID id;
private UUID senderId;
private String senderNickname;
private long timestamp;
private String message;
public Chat(UUID senderId, String senderNickname, long timestamp, String message) {
this.id = null;
this.senderId = senderId;
this.senderNickname = senderNickname;
this.timestamp = timestamp;
this.message = message;
}
public Chat(String message) {
this(null, null, System.currentTimeMillis(), message);
}
@Override
public int getByteCount() {
return 2 * UUID_BYTES + Long.BYTES + getByteSize(this.senderNickname) + getByteSize(this.message);
}
@Override
public void write(DataOutputStream o) throws IOException {
writeUUID(this.id, o);
writeUUID(this.senderId, o);
writeString(this.senderNickname, o);
o.writeLong(this.timestamp);
writeString(this.message, o);
}
@Override
public void read(DataInputStream i) throws IOException {
this.id = readUUID(i);
this.senderId = readUUID(i);
this.senderNickname = readString(i);
this.timestamp = i.readLong();
this.message = readString(i);
}
@Override
public String toString() {
return String.format("%s: %s", this.senderNickname, this.message);
}
@Override
public boolean equals(Object o) {
if (o.getClass().equals(this.getClass())) {
Chat other = (Chat) o;
if (Objects.equals(this.getId(), other.getId())) return true;
return this.getSenderId().equals(other.getSenderId()) &&
this.getTimestamp() == other.getTimestamp() &&
this.getSenderNickname().equals(other.getSenderNickname()) &&
this.message.length() == other.message.length();
}
return false;
}
}

View File

@ -1,43 +0,0 @@
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.*;
/**
* 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
@NoArgsConstructor
@AllArgsConstructor
public class ChatHistoryResponse implements Message {
private UUID channelId;
List<Chat> messages;
@Override
public int getByteCount() {
return UUID_BYTES + getByteSize(messages);
}
@Override
public void write(DataOutputStream o) throws IOException {
writeUUID(this.channelId, o);
writeList(this.messages, o);
}
@Override
public void read(DataInputStream i) throws IOException {
this.channelId = readUUID(i);
this.messages = readList(Chat.class, i);
}
}

View File

@ -1,56 +0,0 @@
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.*;
/**
* This message is sent by clients when they indicate that they would like to
* create a new thread in their current channel.
* <p>
* Conversely, this message is also sent by the server when a thread has
* been created by someone, and all clients need to be notified so that they
* can properly display to the user that a message has been turned into a
* thread.
* </p>
*/
@Data
@NoArgsConstructor
public class CreateThread implements Message {
/**
* The id of the message from which the thread will be created. This will
* serve as the entry point of the thread, and the unique identifier for the
* thread.
*/
private UUID messageId;
/**
* The title for the thread. This may be null, in which case the thread does
* not have any title.
*/
private String title;
@Override
public int getByteCount() {
return UUID_BYTES + getByteSize(title);
}
@Override
public void write(DataOutputStream o) throws IOException {
writeUUID(this.messageId, o);
writeString(this.title, o);
}
@Override
public void read(DataInputStream i) throws IOException {
this.messageId = readUUID(i);
this.title = readString(i);
}
}

View File

@ -1,24 +1,15 @@
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 static nl.andrewl.concord_core.msg.MessageUtils.*;
/**
* Error message which can be sent between either the server or client to
* indicate an unsavory situation.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Error implements Message {
public record Error (
Level level,
String message
) implements Message {
/**
* The error level gives an indication as to the severity of the error.
* Warnings indicate that a user has attempted to do something which they
@ -27,9 +18,6 @@ public class Error implements Message {
*/
public enum Level {WARNING, ERROR}
private Level level;
private String message;
public static Error warning(String message) {
return new Error(Level.WARNING, message);
}
@ -37,21 +25,4 @@ public class Error implements Message {
public static Error error(String message) {
return new Error(Level.ERROR, message);
}
@Override
public int getByteCount() {
return Integer.BYTES + getByteSize(this.message);
}
@Override
public void write(DataOutputStream o) throws IOException {
writeEnum(this.level, o);
writeString(this.message, o);
}
@Override
public void read(DataInputStream i) throws IOException {
this.level = readEnum(Level.class, i);
this.message = readString(i);
}
}

View File

@ -1,56 +0,0 @@
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 static nl.andrewl.concord_core.msg.MessageUtils.*;
/**
* This message is sent from the client to a server, to provide identification
* information about the client to the server when the connection is started.
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Identification implements Message {
/**
* The nickname that a client wants to be identified by when in the server.
* If a valid session token is provided, this can be left as null, and the
* user will be given the same nickname they had in their previous session.
*/
private String nickname;
/**
* A session token that's used to uniquely identify this client as the same
* as one who has previously connected to the server. If this is null, the
* client is indicating that they have not connected to this server before.
*/
private String sessionToken;
public Identification(String nickname) {
this.nickname = nickname;
}
@Override
public int getByteCount() {
return getByteSize(this.nickname) + getByteSize(sessionToken);
}
@Override
public void write(DataOutputStream o) throws IOException {
writeString(this.nickname, o);
writeString(this.sessionToken, o);
}
@Override
public void read(DataInputStream i) throws IOException {
this.nickname = readString(i);
this.sessionToken = readString(i);
}
}

View File

@ -1,52 +0,0 @@
package nl.andrewl.concord_core.msg.types;
import lombok.Getter;
import lombok.NoArgsConstructor;
import nl.andrewl.concord_core.msg.Message;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
/**
* This message is sent as the first message from both the server and the client
* to establish an end-to-end encryption via a key exchange.
*/
@Getter
@NoArgsConstructor
public class KeyData implements Message {
private byte[] iv;
private byte[] salt;
private byte[] publicKey;
public KeyData(byte[] iv, byte[] salt, byte[] publicKey) {
this.iv = iv;
this.salt = salt;
this.publicKey = publicKey;
}
@Override
public int getByteCount() {
return Integer.BYTES * 3 + iv.length + salt.length + publicKey.length;
}
@Override
public void write(DataOutputStream o) throws IOException {
o.writeInt(iv.length);
o.write(iv);
o.writeInt(salt.length);
o.write(salt);
o.writeInt(publicKey.length);
o.write(publicKey);
}
@Override
public void read(DataInputStream i) throws IOException {
int ivLength = i.readInt();
this.iv = i.readNBytes(ivLength);
int saltLength = i.readInt();
this.salt = i.readNBytes(saltLength);
int publicKeyLength = i.readInt();
this.publicKey = i.readNBytes(publicKeyLength);
}
}

View File

@ -1,67 +0,0 @@
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.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>
* <p>
* Clients can also send this message and provide the id of another client
* to request that they enter a private message channel with the referenced
* client.
* </p>
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class MoveToChannel implements Message {
/**
* The id of the channel that the client is requesting or being moved to, or
* the id of another client that the user wishes to begin private messaging
* with.
*/
private UUID id;
/**
* The name of the channel that the client is moved to. This is null in
* cases where the client is requesting to move to a channel, and is only
* provided by the server when it moves a client.
*/
private String channelName;
public MoveToChannel(UUID channelId) {
this.id = channelId;
}
@Override
public int getByteCount() {
return UUID_BYTES + getByteSize(this.channelName);
}
@Override
public void write(DataOutputStream o) throws IOException {
writeUUID(this.id, o);
writeString(this.channelName, o);
}
@Override
public void read(DataInputStream i) throws IOException {
this.id = readUUID(i);
this.channelName = readString(i);
}
}

View File

@ -1,73 +1,18 @@
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.*;
/**
* Metadata is sent by the server to clients to inform them of the structure of
* the server. This includes basic information about the server's own properties
* as well as information about all top-level channels.
*/
@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);
this.channels = readList(ChannelData.class, i);
}
public record ServerMetaData (String name, ChannelData[] channels) implements Message {
/**
* Metadata about a top-level channel in the server which is visible and
* joinable for a user.
*/
@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);
}
}
public static record ChannelData (UUID id, String name) implements Message {}
}

View File

@ -1,40 +1,10 @@
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 static nl.andrewl.concord_core.msg.MessageUtils.*;
/**
* This message is sent from the server to the client whenever a change happens
* which requires the server to notify clients about a change of the list of
* global users.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ServerUsers implements Message {
private List<UserData> users;
@Override
public int getByteCount() {
return getByteSize(this.users);
}
@Override
public void write(DataOutputStream o) throws IOException {
writeList(this.users, o);
}
@Override
public void read(DataInputStream i) throws IOException {
this.users = readList(UserData.class, i);
}
}
public record ServerUsers (UserData[] users) implements Message {}

View File

@ -1,72 +0,0 @@
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.UUID;
import static nl.andrewl.concord_core.msg.MessageUtils.*;
/**
* 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.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ServerWelcome implements Message {
/**
* The unique id of this client.
*/
private UUID clientId;
/**
* The token which this client can use to reconnect to the server later and
* still be recognized as the same user.
*/
private String sessionToken;
/**
* The id of the channel that the user has been placed in.
*/
private UUID currentChannelId;
/**
* The name of the channel that the user has been placed in.
*/
private String currentChannelName;
/**
* Information about the server's structure.
*/
private ServerMetaData metaData;
@Override
public int getByteCount() {
return 2 * UUID_BYTES + getByteSize(this.sessionToken) + getByteSize(this.currentChannelName) + this.metaData.getByteCount();
}
@Override
public void write(DataOutputStream o) throws IOException {
writeUUID(this.clientId, o);
writeString(this.sessionToken, o);
writeUUID(this.currentChannelId, o);
writeString(this.currentChannelName, o);
this.metaData.write(o);
}
@Override
public void read(DataInputStream i) throws IOException {
this.clientId = readUUID(i);
this.sessionToken = readString(i);
this.currentChannelId = readUUID(i);
this.metaData = new ServerMetaData();
this.currentChannelName = readString(i);
this.metaData.read(i);
}
}

View File

@ -1,43 +1,11 @@
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.UUID;
import static nl.andrewl.concord_core.msg.MessageUtils.*;
import static nl.andrewl.concord_core.msg.MessageUtils.readString;
/**
* Standard set of user data that is used mainly as a component of other more
* complex messages.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserData 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);
}
}
public record UserData (UUID id, String name) implements Message {}

View File

@ -0,0 +1,20 @@
package nl.andrewl.concord_core.msg.types.channel;
import nl.andrewl.concord_core.msg.Message;
import java.util.UUID;
/**
* This message is sent by clients when they indicate that they would like to
* create a new thread in their current channel.
* <p>
* Conversely, this message is also sent by the server when a thread has
* been created by someone, and all clients need to be notified so that they
* can properly display to the user that a message has been turned into a
* thread.
* </p>
*
* @param messageId The id of the message that a thread will be/is attached to.
* @param title The title of the thread. This may be null.
*/
public record CreateThread (UUID messageId, String title) implements Message {}

View File

@ -0,0 +1,32 @@
package nl.andrewl.concord_core.msg.types.channel;
import nl.andrewl.concord_core.msg.Message;
import java.util.UUID;
/**
* 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>
* <p>
* Clients can also send this message and provide the id of another client
* to request that they enter a private message channel with the referenced
* client.
* </p>
* @param id The id of the channel that the client is requesting or being moved
* to, or the id of another client that the user wishes to begin
* private messaging with.
* @param channelName The name of the channel that the client is moved to. This
* is null in cases where the client is requesting to move to
* a channel, and is only provided by the server when it
* moves a client.
*/
public record MoveToChannel (UUID id, String channelName) implements Message {
public MoveToChannel(UUID id) {
this(id, null);
}
}

View File

@ -0,0 +1,43 @@
package nl.andrewl.concord_core.msg.types.chat;
import nl.andrewl.concord_core.msg.Message;
import java.util.Objects;
import java.util.UUID;
/**
* This message contains information about a chat message that a user sent.
*/
public record Chat (
UUID id, UUID senderId, String senderNickname, long timestamp, String message
) implements Message {
public Chat(UUID senderId, String senderNickname, long timestamp, String message) {
this(null, senderId, senderNickname, timestamp, message);
}
public Chat(String message) {
this(null, null, System.currentTimeMillis(), message);
}
public Chat(UUID newId, Chat original) {
this(newId, original.senderId, original.senderNickname, original.timestamp, original.message);
}
@Override
public String toString() {
return String.format("%s: %s", this.senderNickname, this.message);
}
@Override
public boolean equals(Object o) {
if (o.getClass().equals(this.getClass())) {
Chat other = (Chat) o;
if (Objects.equals(this.id, other.id)) return true;
return this.senderId.equals(other.senderId) &&
this.timestamp == other.timestamp &&
this.senderNickname.equals(other.senderNickname) &&
this.message.length() == other.message.length();
}
return false;
}
}

View File

@ -1,20 +1,12 @@
package nl.andrewl.concord_core.msg.types;
package nl.andrewl.concord_core.msg.types.chat;
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.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import static nl.andrewl.concord_core.msg.MessageUtils.*;
/**
* A message which clients can send to the server to request some messages from
* the server's history of all sent messages from a particular source. Every
@ -51,20 +43,15 @@ import static nl.andrewl.concord_core.msg.MessageUtils.*;
* the list of messages is always sorted by the timestamp.
* </p>
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatHistoryRequest implements Message {
private UUID channelId;
private String query;
public record ChatHistoryRequest (UUID channelId, String query) implements Message {
public ChatHistoryRequest(UUID channelId) {
this(channelId, "");
}
public ChatHistoryRequest(UUID channelId, Map<String, String> params) {
this.channelId = channelId;
this.query = params.entrySet().stream()
this(
channelId,
params.entrySet().stream()
.map(entry -> {
if (entry.getKey().contains(";") || entry.getKey().contains("=")) {
throw new IllegalArgumentException("Parameter key \"" + entry.getKey() + "\" contains invalid characters.");
@ -74,7 +61,8 @@ public class ChatHistoryRequest implements Message {
}
return entry.getKey() + "=" + entry.getValue();
})
.collect(Collectors.joining(";"));
.collect(Collectors.joining(";"))
);
}
/**
@ -92,21 +80,4 @@ public class ChatHistoryRequest implements Message {
}
return params;
}
@Override
public int getByteCount() {
return UUID_BYTES + getByteSize(this.query);
}
@Override
public void write(DataOutputStream o) throws IOException {
writeUUID(this.channelId, o);
writeString(this.query, o);
}
@Override
public void read(DataInputStream i) throws IOException {
this.channelId = readUUID(i);
this.query = readString(i);
}
}

View File

@ -0,0 +1,11 @@
package nl.andrewl.concord_core.msg.types.chat;
import nl.andrewl.concord_core.msg.Message;
import java.util.UUID;
/**
* The response that a server sends to a {@link ChatHistoryRequest}. The list of
* messages is ordered by timestamp, with the newest messages appearing first.
*/
public record ChatHistoryResponse (UUID channelId, Chat[] messages) implements Message {}

View File

@ -0,0 +1,11 @@
package nl.andrewl.concord_core.msg.types.client_setup;
import nl.andrewl.concord_core.msg.Message;
/**
* This message is sent from the client to a server, to provide identification
* information about the client to the server when the connection is started.
*
* @param nickname
*/
public record Identification(String nickname, String sessionToken) implements Message {}

View File

@ -0,0 +1,9 @@
package nl.andrewl.concord_core.msg.types.client_setup;
import nl.andrewl.concord_core.msg.Message;
/**
* This message is sent as the first message from both the server and the client
* to establish an end-to-end encryption via a key exchange.
*/
public record KeyData (byte[] iv, byte[] salt, byte[] publicKey) implements Message {}

View File

@ -0,0 +1,9 @@
package nl.andrewl.concord_core.msg.types.client_setup;
import nl.andrewl.concord_core.msg.Message;
/**
* The data that new users should send to a server in order to register in that
* server.
*/
public record Registration (String username, String password) implements Message {}

View File

@ -0,0 +1,25 @@
package nl.andrewl.concord_core.msg.types.client_setup;
import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.types.ServerMetaData;
import java.util.UUID;
/**
* 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.
*
* @param clientId The unique id of this client.
* @param sessionToken The token which this client can use to reconnect to the
* server later and still be recognized as the same user.
* @param currentChannelId The id of the channel that the user is placed in.
* @param currentChannelName The name of the channel that the user is placed in.
* @param metaData Information about the server's structure.
*/
public record ServerWelcome (
UUID clientId,
String sessionToken,
UUID currentChannelId,
String currentChannelName,
ServerMetaData metaData
) implements Message {}

View File

@ -0,0 +1,108 @@
package nl.andrewl.concord_core.util;
import nl.andrewl.concord_core.msg.Message;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
/**
* A more complex output stream which redefines certain methods for convenience
* with method chaining.
*/
public class ChainedDataOutputStream {
private final DataOutputStream out;
public ChainedDataOutputStream(DataOutputStream out) {
this.out = out;
}
public ChainedDataOutputStream writeInt(int x) throws IOException {
out.writeInt(x);
return this;
}
public ChainedDataOutputStream writeString(String s) throws IOException {
if (s == null) {
out.writeInt(-1);
} else {
out.writeInt(s.length());
out.write(s.getBytes(StandardCharsets.UTF_8));
}
return this;
}
public ChainedDataOutputStream writeStrings(String... strings) throws IOException {
for (var s : strings) {
writeString(s);
}
return this;
}
public ChainedDataOutputStream writeEnum(Enum<?> value) throws IOException {
if (value == null) {
out.writeInt(-1);
} else {
out.writeInt(value.ordinal());
}
return this;
}
public ChainedDataOutputStream writeUUID(UUID uuid) throws IOException {
if (uuid == null) {
out.writeLong(-1);
out.writeLong(-1);
} else {
out.writeLong(uuid.getMostSignificantBits());
out.writeLong(uuid.getLeastSignificantBits());
}
return this;
}
public <T extends Message> ChainedDataOutputStream writeArray(T[] array) throws IOException {
this.out.writeInt(array.length);
for (var item : array) {
item.getType().writer().write(item, this);
}
return this;
}
public <T extends Message> ChainedDataOutputStream writeMessage(Message msg) throws IOException {
msg.getType().writer().write(msg, this);
return this;
}
/**
* Writes an object to the stream.
* @param o The object to write.
* @param type The object's type. This is needed in case the object itself
* is null, which may be the case for some strings or ids.
* @return The chained output stream.
* @throws IOException If an error occurs.
*/
public ChainedDataOutputStream writeObject(Object o, Class<?> type) throws IOException {
if (type.equals(Integer.class) || type.equals(int.class)) {
this.writeInt((Integer) o);
} else if (type.equals(Long.class) || type.equals(long.class)) {
this.out.writeLong((Long) o);
} else if (type.equals(String.class)) {
this.writeString((String) o);
} else if (type.equals(UUID.class)) {
this.writeUUID((UUID) o);
} else if (type.isEnum()) {
this.writeEnum((Enum<?>) o);
} else if (type.equals(byte[].class)) {
byte[] b = (byte[]) o;
this.writeInt(b.length);
this.out.write(b);
} else if (type.isArray() && Message.class.isAssignableFrom(type.getComponentType())) {
this.writeArray((Message[]) o);
} else if (Message.class.isAssignableFrom(type)) {
this.writeMessage((Message) o);
} else {
throw new IOException("Unsupported object type: " + o.getClass().getSimpleName());
}
return this;
}
}

View File

@ -0,0 +1,88 @@
package nl.andrewl.concord_core.util;
import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.MessageType;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Array;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
/**
* An extended output stream which contains additional methods for reading more
* complex types that are used by the Concord system.
*/
public class ExtendedDataInputStream extends DataInputStream {
public ExtendedDataInputStream(InputStream in) {
super(in);
}
public String readString() throws IOException {
int length = super.readInt();
if (length == -1) return null;
if (length == 0) return "";
byte[] data = new byte[length];
int read = super.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 <T extends Enum<?>> T readEnum(Class<T> e) throws IOException {
int ordinal = super.readInt();
if (ordinal == -1) return null;
return e.getEnumConstants()[ordinal];
}
public UUID readUUID() throws IOException {
long a = super.readLong();
long b = super.readLong();
if (a == -1 && b == -1) {
return null;
}
return new UUID(a, b);
}
@SuppressWarnings("unchecked")
public <T extends Message> T[] readArray(MessageType<T> type) throws IOException {
int length = super.readInt();
T[] array = (T[]) Array.newInstance(type.messageClass(), length);
for (int i = 0; i < length; i++) {
array[i] = type.reader().read(this);
}
return array;
}
/**
* Reads an object from the stream that is of a certain expected type.
* @param type The type of object to read.
* @return The object that was read.
* @throws IOException If an error occurs while reading.
*/
@SuppressWarnings("unchecked")
public Object readObject(Class<?> type) throws IOException {
if (type.equals(Integer.class) || type.equals(int.class)) {
return this.readInt();
} else if (type.equals(Long.class) || type.equals(long.class)) {
return this.readLong();
} else if (type.equals(String.class)) {
return this.readString();
} else if (type.equals(UUID.class)) {
return this.readUUID();
} else if (type.isEnum()) {
return this.readEnum((Class<? extends Enum<?>>) type);
} else if (type.isAssignableFrom(byte[].class)) {
int length = this.readInt();
return this.readNBytes(length);
} else if (type.isArray() && Message.class.isAssignableFrom(type.getComponentType())) {
var messageType = MessageType.get((Class<? extends Message>) type.getComponentType());
return this.readArray(messageType);
} else if (Message.class.isAssignableFrom(type)) {
var messageType = MessageType.get((Class<? extends Message>) type);
return messageType.reader().read(this);
} else {
throw new IOException("Unsupported object type: " + type.getSimpleName());
}
}
}

View File

@ -0,0 +1,3 @@
package nl.andrewl.concord_core.util;
public record Triple<A, B, C> (A first, B second, C third) {}

View File

@ -22,7 +22,6 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* The main server implementation, which handles accepting new clients.
@ -128,8 +127,8 @@ public class ConcordServer implements Runnable {
this.config.getName(),
this.channelManager.getChannels().stream()
.map(channel -> new ServerMetaData.ChannelData(channel.getId(), channel.getName()))
.sorted(Comparator.comparing(ServerMetaData.ChannelData::getName))
.collect(Collectors.toList())
.sorted(Comparator.comparing(ServerMetaData.ChannelData::name))
.toList().toArray(new ServerMetaData.ChannelData[0])
);
}

View File

@ -76,7 +76,7 @@ public class Channel implements Comparable<Channel> {
* @throws IOException If an error occurs.
*/
public void sendMessage(Message msg) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream(msg.getByteCount() + 1);
ByteArrayOutputStream baos = new ByteArrayOutputStream(msg.byteSize() + 1);
this.server.getSerializer().writeMessage(msg, baos);
byte[] data = baos.toByteArray();
for (var client : this.connectedClients) {
@ -93,7 +93,7 @@ public class Channel implements Comparable<Channel> {
for (var clientThread : this.getConnectedClients()) {
users.add(clientThread.toData());
}
users.sort(Comparator.comparing(UserData::getName));
users.sort(Comparator.comparing(UserData::name));
return users;
}

View File

@ -1,6 +1,6 @@
package nl.andrewl.concord_server.channel;
import nl.andrewl.concord_core.msg.types.MoveToChannel;
import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
import nl.andrewl.concord_server.ConcordServer;
import nl.andrewl.concord_server.client.ClientThread;
import nl.andrewl.concord_server.util.CollectionUtils;

View File

@ -15,7 +15,7 @@ public class ListClientsCommand implements ServerCliCommand {
} else {
StringBuilder sb = new StringBuilder("Online Users:\n");
for (var userData : users) {
sb.append("\t").append(userData.getName()).append(" (").append(userData.getId()).append(")\n");
sb.append("\t").append(userData.name()).append(" (").append(userData.id()).append(")\n");
}
System.out.print(sb);
}

View File

@ -2,7 +2,10 @@ package nl.andrewl.concord_server.client;
import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.types.Error;
import nl.andrewl.concord_core.msg.types.*;
import nl.andrewl.concord_core.msg.types.ServerUsers;
import nl.andrewl.concord_core.msg.types.UserData;
import nl.andrewl.concord_core.msg.types.client_setup.Identification;
import nl.andrewl.concord_core.msg.types.client_setup.ServerWelcome;
import nl.andrewl.concord_server.ConcordServer;
import nl.andrewl.concord_server.util.CollectionUtils;
import nl.andrewl.concord_server.util.StringUtils;
@ -54,7 +57,7 @@ public class ClientManager {
public void handleLogIn(Identification identification, ClientThread clientThread) {
ClientConnectionData data;
try {
data = identification.getSessionToken() == null ? getNewClientData(identification) : getClientDataFromDb(identification);
data = identification.sessionToken() == null ? getNewClientData(identification) : getClientDataFromDb(identification);
} catch (InvalidIdentificationException e) {
clientThread.sendToClient(Error.warning(e.getMessage()));
return;
@ -75,7 +78,7 @@ public class ClientManager {
data.newClient ? " for the first time" : "",
defaultChannel
);
this.broadcast(new ServerUsers(this.getClients()));
this.broadcast(new ServerUsers(this.getClients().toArray(new UserData[0])));
}
/**
@ -89,7 +92,7 @@ public class ClientManager {
client.getCurrentChannel().removeClient(client);
client.shutdown();
System.out.println("Client " + client + " has disconnected.");
this.broadcast(new ServerUsers(this.getClients()));
this.broadcast(new ServerUsers(this.getClients().toArray(new UserData[0])));
}
}
@ -99,7 +102,7 @@ public class ClientManager {
* @param message The message to send.
*/
public void broadcast(Message message) {
ByteArrayOutputStream baos = new ByteArrayOutputStream(message.getByteCount());
ByteArrayOutputStream baos = new ByteArrayOutputStream(message.byteSize());
try {
this.server.getSerializer().writeMessage(message, baos);
byte[] data = baos.toByteArray();
@ -129,11 +132,11 @@ public class ClientManager {
private static record ClientConnectionData(UUID id, String nickname, String sessionToken, boolean newClient) {}
private ClientConnectionData getClientDataFromDb(Identification identification) throws InvalidIdentificationException {
var cursor = this.userCollection.find(Filters.eq("sessionToken", identification.getSessionToken()));
var cursor = this.userCollection.find(Filters.eq("sessionToken", identification.sessionToken()));
Document doc = cursor.firstOrDefault();
if (doc != null) {
UUID id = doc.get("id", UUID.class);
String nickname = identification.getNickname();
String nickname = identification.nickname();
if (nickname != null) {
doc.put("nickname", nickname);
} else {
@ -150,7 +153,7 @@ public class ClientManager {
private ClientConnectionData getNewClientData(Identification identification) throws InvalidIdentificationException {
UUID id = this.server.getIdProvider().newId();
String nickname = identification.getNickname();
String nickname = identification.nickname();
if (nickname == null) {
throw new InvalidIdentificationException("Missing nickname.");
}

View File

@ -4,7 +4,7 @@ import lombok.Getter;
import lombok.Setter;
import nl.andrewl.concord_core.msg.Encryption;
import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.types.Identification;
import nl.andrewl.concord_core.msg.types.client_setup.Identification;
import nl.andrewl.concord_core.msg.types.UserData;
import nl.andrewl.concord_server.ConcordServer;
import nl.andrewl.concord_server.channel.Channel;

View File

@ -1,11 +1,10 @@
package nl.andrewl.concord_server.event;
import nl.andrewl.concord_core.msg.types.Error;
import nl.andrewl.concord_core.msg.types.MoveToChannel;
import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
import nl.andrewl.concord_server.ConcordServer;
import nl.andrewl.concord_server.client.ClientThread;
import java.util.List;
import java.util.Set;
/**
@ -17,11 +16,11 @@ import java.util.Set;
public class ChannelMoveHandler implements MessageHandler<MoveToChannel> {
@Override
public void handle(MoveToChannel msg, ClientThread client, ConcordServer server) {
var optionalChannel = server.getChannelManager().getChannelById(msg.getId());
var optionalChannel = server.getChannelManager().getChannelById(msg.id());
if (optionalChannel.isPresent()) {
server.getChannelManager().moveToChannel(client, optionalChannel.get());
} else {
var optionalClient = server.getClientManager().getClientById(msg.getId());
var optionalClient = server.getClientManager().getClientById(msg.id());
if (optionalClient.isPresent()) {
var privateChannel = server.getChannelManager().getPrivateChannel(Set.of(
client.getClientId(),

View File

@ -1,7 +1,7 @@
package nl.andrewl.concord_server.event;
import nl.andrewl.concord_core.msg.types.Chat;
import nl.andrewl.concord_core.msg.types.Error;
import nl.andrewl.concord_core.msg.types.chat.Chat;
import nl.andrewl.concord_server.ConcordServer;
import nl.andrewl.concord_server.client.ClientThread;
import org.dizitart.no2.Document;
@ -17,7 +17,7 @@ import java.util.Map;
public class ChatHandler implements MessageHandler<Chat> {
@Override
public void handle(Chat msg, ClientThread client, ConcordServer server) throws IOException {
if (msg.getMessage().length() > server.getConfig().getMaxMessageLength()) {
if (msg.message().length() > server.getConfig().getMaxMessageLength()) {
client.getCurrentChannel().sendMessage(Error.warning("Message is too long."));
return;
}
@ -27,17 +27,17 @@ public class ChatHandler implements MessageHandler<Chat> {
malicious UUID, so we overwrite it with a server-generated id which we
know is safe.
*/
msg.setId(server.getIdProvider().newId());
msg = new Chat(server.getIdProvider().newId(), msg);
var collection = client.getCurrentChannel().getMessageCollection();
Document doc = new Document(Map.of(
"id", msg.getId(),
"senderId", msg.getSenderId(),
"senderNickname", msg.getSenderNickname(),
"timestamp", msg.getTimestamp(),
"message", msg.getMessage()
"id", msg.id(),
"senderId", msg.senderId(),
"senderNickname", msg.senderNickname(),
"timestamp", msg.timestamp(),
"message", msg.message()
));
collection.insert(doc);
System.out.printf("#%s | %s: %s\n", client.getCurrentChannel(), client.getClientNickname(), msg.getMessage());
System.out.printf("#%s | %s: %s\n", client.getCurrentChannel(), client.getClientNickname(), msg.message());
client.getCurrentChannel().sendMessage(msg);
}
}

View File

@ -1,9 +1,9 @@
package nl.andrewl.concord_server.event;
import nl.andrewl.concord_core.msg.types.Chat;
import nl.andrewl.concord_core.msg.types.ChatHistoryRequest;
import nl.andrewl.concord_core.msg.types.ChatHistoryResponse;
import nl.andrewl.concord_core.msg.types.Error;
import nl.andrewl.concord_core.msg.types.chat.Chat;
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryRequest;
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryResponse;
import nl.andrewl.concord_server.ConcordServer;
import nl.andrewl.concord_server.channel.Channel;
import nl.andrewl.concord_server.client.ClientThread;
@ -19,10 +19,10 @@ public class ChatHistoryRequestHandler implements MessageHandler<ChatHistoryRequ
@Override
public void handle(ChatHistoryRequest msg, ClientThread client, ConcordServer server) {
// First try and find a public channel with the given id.
var channel = server.getChannelManager().getChannelById(msg.getChannelId()).orElse(null);
var channel = server.getChannelManager().getChannelById(msg.channelId()).orElse(null);
if (channel == null) {
// Couldn't find a public channel, so look for a private channel this client is involved in.
channel = server.getChannelManager().getPrivateChannel(client.getClientId(), msg.getChannelId()).orElse(null);
channel = server.getChannelManager().getPrivateChannel(client.getClientId(), msg.channelId()).orElse(null);
}
// If we couldn't find a public or private channel, give up.
if (channel == null) {
@ -55,7 +55,7 @@ public class ChatHistoryRequestHandler implements MessageHandler<ChatHistoryRequ
for (var doc : cursor) {
chats.add(this.read(doc));
}
client.sendToClient(new ChatHistoryResponse(channel.getId(), chats));
client.sendToClient(new ChatHistoryResponse(channel.getId(), chats.toArray(new Chat[0])));
}
/**
@ -89,7 +89,7 @@ public class ChatHistoryRequestHandler implements MessageHandler<ChatHistoryRequ
chats.add(this.read(doc));
}
Collections.reverse(chats);
return new ChatHistoryResponse(channel.getId(), chats);
return new ChatHistoryResponse(channel.getId(), chats.toArray(new Chat[0]));
}
/**

View File

@ -2,9 +2,9 @@ package nl.andrewl.concord_server.event;
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.ChatHistoryRequest;
import nl.andrewl.concord_core.msg.types.MoveToChannel;
import nl.andrewl.concord_core.msg.types.chat.Chat;
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryRequest;
import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
import nl.andrewl.concord_server.ConcordServer;
import nl.andrewl.concord_server.client.ClientThread;