From 1e1d861d9166ab138f38e2f313b0a88eb7ebe863 Mon Sep 17 00:00:00 2001 From: MrLetsplay Date: Sat, 25 Nov 2023 16:09:27 +0100 Subject: [PATCH] Add basic networking --- pom.xml | 31 ++-- .../shareclientcore/connection/Change.java | 5 + .../connection/ChangeType.java | 9 ++ .../connection/DummyConnection.java | 32 ++++ .../connection/RemoteConnection.java | 20 +++ .../connection/RemoteListener.java | 10 ++ .../connection/WebSocketConnection.java | 146 ++++++++++++++++++ .../document/ArrayCharBag.java | 2 +- .../shareclientcore/document/Char.java | 23 +++ .../document/DocumentListener.java | 12 ++ .../{Document.java => SharedDocument.java} | 69 ++++++++- .../shareclientcore/DocumentTest.java | 11 +- 12 files changed, 343 insertions(+), 27 deletions(-) create mode 100644 src/main/java/me/mrletsplay/shareclientcore/connection/Change.java create mode 100644 src/main/java/me/mrletsplay/shareclientcore/connection/ChangeType.java create mode 100644 src/main/java/me/mrletsplay/shareclientcore/connection/DummyConnection.java create mode 100644 src/main/java/me/mrletsplay/shareclientcore/connection/RemoteConnection.java create mode 100644 src/main/java/me/mrletsplay/shareclientcore/connection/RemoteListener.java create mode 100644 src/main/java/me/mrletsplay/shareclientcore/connection/WebSocketConnection.java create mode 100644 src/main/java/me/mrletsplay/shareclientcore/document/DocumentListener.java rename src/main/java/me/mrletsplay/shareclientcore/document/{Document.java => SharedDocument.java} (51%) diff --git a/pom.xml b/pom.xml index ecef4b1..f5c1a04 100644 --- a/pom.xml +++ b/pom.xml @@ -38,19 +38,19 @@ - - org.apache.maven.plugins - maven-source-plugin - 3.3.0 - - - attach-sources - - jar - - - - + + org.apache.maven.plugins + maven-source-plugin + 3.3.0 + + + attach-sources + + jar + + + + @@ -61,5 +61,10 @@ 5.10.1 test + + org.java-websocket + Java-WebSocket + 1.5.4 + \ No newline at end of file diff --git a/src/main/java/me/mrletsplay/shareclientcore/connection/Change.java b/src/main/java/me/mrletsplay/shareclientcore/connection/Change.java new file mode 100644 index 0000000..c3eef74 --- /dev/null +++ b/src/main/java/me/mrletsplay/shareclientcore/connection/Change.java @@ -0,0 +1,5 @@ +package me.mrletsplay.shareclientcore.connection; + +import me.mrletsplay.shareclientcore.document.Char; + +public record Change(int document, ChangeType type, Char character) {} diff --git a/src/main/java/me/mrletsplay/shareclientcore/connection/ChangeType.java b/src/main/java/me/mrletsplay/shareclientcore/connection/ChangeType.java new file mode 100644 index 0000000..21d0d15 --- /dev/null +++ b/src/main/java/me/mrletsplay/shareclientcore/connection/ChangeType.java @@ -0,0 +1,9 @@ +package me.mrletsplay.shareclientcore.connection; + +public enum ChangeType { + + ADD, + REMOVE, + ; + +} diff --git a/src/main/java/me/mrletsplay/shareclientcore/connection/DummyConnection.java b/src/main/java/me/mrletsplay/shareclientcore/connection/DummyConnection.java new file mode 100644 index 0000000..c95bd4c --- /dev/null +++ b/src/main/java/me/mrletsplay/shareclientcore/connection/DummyConnection.java @@ -0,0 +1,32 @@ +package me.mrletsplay.shareclientcore.connection; + +import java.io.IOException; + +public class DummyConnection implements RemoteConnection { + + @Override + public void connect() throws IOException, InterruptedException { + + } + + @Override + public int retrieveSiteID() { + return 0; + } + + @Override + public void send(Change... changes) { + + } + + @Override + public void addListener(RemoteListener listener) { + + } + + @Override + public void removeListener(RemoteListener listener) { + + } + +} diff --git a/src/main/java/me/mrletsplay/shareclientcore/connection/RemoteConnection.java b/src/main/java/me/mrletsplay/shareclientcore/connection/RemoteConnection.java new file mode 100644 index 0000000..b0aaa7a --- /dev/null +++ b/src/main/java/me/mrletsplay/shareclientcore/connection/RemoteConnection.java @@ -0,0 +1,20 @@ +package me.mrletsplay.shareclientcore.connection; + +import java.io.IOException; + +/** + * Represents a connection to a remote user or server + */ +public interface RemoteConnection { + + public void connect() throws IOException, InterruptedException; + + public int retrieveSiteID(); + + public void send(Change... changes); + + public void addListener(RemoteListener listener); + + public void removeListener(RemoteListener listener); + +} diff --git a/src/main/java/me/mrletsplay/shareclientcore/connection/RemoteListener.java b/src/main/java/me/mrletsplay/shareclientcore/connection/RemoteListener.java new file mode 100644 index 0000000..7950c8a --- /dev/null +++ b/src/main/java/me/mrletsplay/shareclientcore/connection/RemoteListener.java @@ -0,0 +1,10 @@ +package me.mrletsplay.shareclientcore.connection; + +/** + * Represents something that can receive remote changes + */ +public interface RemoteListener { + + public void onRemoteChange(Change... changes); + +} diff --git a/src/main/java/me/mrletsplay/shareclientcore/connection/WebSocketConnection.java b/src/main/java/me/mrletsplay/shareclientcore/connection/WebSocketConnection.java new file mode 100644 index 0000000..cbaed7f --- /dev/null +++ b/src/main/java/me/mrletsplay/shareclientcore/connection/WebSocketConnection.java @@ -0,0 +1,146 @@ +package me.mrletsplay.shareclientcore.connection; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.util.HashSet; +import java.util.Map; +import java.util.Random; +import java.util.Set; + +import org.java_websocket.client.WebSocketClient; +import org.java_websocket.handshake.ServerHandshake; + +import me.mrletsplay.shareclientcore.document.Char; +import me.mrletsplay.shareclientcore.document.Identifier; + +public class WebSocketConnection implements RemoteConnection { + + private WSClient client; + private Set listeners; + + public WebSocketConnection(URI uri, Map httpHeaders) { + this.client = new WSClient(uri, httpHeaders); + this.listeners = new HashSet<>(); + } + + public WebSocketConnection(URI uri) { + this(uri, null); + } + + @Override + public void connect() throws IOException, InterruptedException { + if(!client.connectBlocking()) throw new IOException("Failed to connect to WebSocket server"); + } + + @Override + public int retrieveSiteID() { + // TODO: implement + return new Random().nextInt(); + } + + @Override + public void send(Change... changes) { + for(Change c : changes) { + client.send(serialize(c)); + } + } + + @Override + public void addListener(RemoteListener listener) { + listeners.add(listener); + } + + @Override + public void removeListener(RemoteListener listener) { + listeners.remove(listener); + } + + private byte[] serialize(Change change) { + try { + ByteArrayOutputStream bOut = new ByteArrayOutputStream(); + DataOutputStream dOut = new DataOutputStream(bOut); + dOut.writeInt(change.document()); + dOut.writeUTF(change.type().name()); + + Char ch = change.character(); + dOut.writeInt(ch.position().length); + for(int i = 0; i < ch.position().length; i++) { + Identifier id = ch.position()[i]; + dOut.writeInt(id.digit()); + dOut.writeInt(id.site()); + } + + dOut.writeInt(ch.lamport()); + dOut.writeChar(ch.value()); + return bOut.toByteArray(); + }catch(IOException e) { + throw new RuntimeException("Something went very wrong", e); + } + } + + private Change deserialize(byte[] bytes) { + try { + DataInputStream dIn = new DataInputStream(new ByteArrayInputStream(bytes)); + int document = dIn.readInt(); + ChangeType type = ChangeType.valueOf(dIn.readUTF()); + + Identifier[] pos = new Identifier[dIn.readInt()]; + for(int i = 0; i < pos.length; i++) { + pos[i] = new Identifier(dIn.readInt(), dIn.readInt()); + } + + int lamport = dIn.readInt(); + char value = dIn.readChar(); + return new Change(document, type, new Char(pos, lamport, value)); + }catch(IllegalArgumentException e) { + throw new IllegalArgumentException("Failed to deserialize change", e); + }catch(IOException e) { + throw new RuntimeException("Something went very wrong", e); + } + } + + private class WSClient extends WebSocketClient { + + public WSClient(URI serverUri) { + super(serverUri); + } + + public WSClient(URI serverUri, Map httpHeaders) { + super(serverUri, httpHeaders); + } + + @Override + public void onOpen(ServerHandshake handshakedata) { + // TODO: request site id + } + + @Override + public void onMessage(String message) { + System.out.println("Got text message: " + message); + } + + @Override + public void onMessage(ByteBuffer bytes) { + byte[] bytesArray = new byte[bytes.remaining()]; + bytes.get(bytesArray); + listeners.forEach(l -> l.onRemoteChange(deserialize(bytesArray))); + } + + @Override + public void onClose(int code, String reason, boolean remote) { + // TODO: handle + } + + @Override + public void onError(Exception ex) { + // TODO: handle + } + + } + +} diff --git a/src/main/java/me/mrletsplay/shareclientcore/document/ArrayCharBag.java b/src/main/java/me/mrletsplay/shareclientcore/document/ArrayCharBag.java index b095b50..9b5aee9 100644 --- a/src/main/java/me/mrletsplay/shareclientcore/document/ArrayCharBag.java +++ b/src/main/java/me/mrletsplay/shareclientcore/document/ArrayCharBag.java @@ -23,7 +23,7 @@ public class ArrayCharBag implements CharBag { // TODO: use binary search while(i < chars.size() && Util.comparePositions(chars.get(i).position(), character.position()) < 0) i++; if(i == chars.size() || Util.comparePositions(chars.get(i).position(), character.position()) != 0) return -1; - chars.remove(character); + chars.remove(i); return i; } diff --git a/src/main/java/me/mrletsplay/shareclientcore/document/Char.java b/src/main/java/me/mrletsplay/shareclientcore/document/Char.java index fcf2338..46fd10c 100644 --- a/src/main/java/me/mrletsplay/shareclientcore/document/Char.java +++ b/src/main/java/me/mrletsplay/shareclientcore/document/Char.java @@ -1,8 +1,31 @@ package me.mrletsplay.shareclientcore.document; +import java.util.Arrays; +import java.util.Objects; + public record Char(Identifier[] position, int lamport, char value) { public static final Char START_OF_DOCUMENT = new Char(new Identifier[] { new Identifier(1, 0) }, 0, '^'); public static final Char END_OF_DOCUMENT = new Char(new Identifier[] { new Identifier(Util.BASE - 1, 0) }, 0, '$'); + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + Arrays.hashCode(position); + result = prime * result + Objects.hash(lamport, value); + return result; + } + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + Char other = (Char) obj; + return lamport == other.lamport && Arrays.equals(position, other.position) && value == other.value; + } + } diff --git a/src/main/java/me/mrletsplay/shareclientcore/document/DocumentListener.java b/src/main/java/me/mrletsplay/shareclientcore/document/DocumentListener.java new file mode 100644 index 0000000..a63de06 --- /dev/null +++ b/src/main/java/me/mrletsplay/shareclientcore/document/DocumentListener.java @@ -0,0 +1,12 @@ +package me.mrletsplay.shareclientcore.document; + +/** + * Represents something that can receive local changes + */ +public interface DocumentListener { + + public void onInsert(int index, char character); + + public void onDelete(int index); + +} diff --git a/src/main/java/me/mrletsplay/shareclientcore/document/Document.java b/src/main/java/me/mrletsplay/shareclientcore/document/SharedDocument.java similarity index 51% rename from src/main/java/me/mrletsplay/shareclientcore/document/Document.java rename to src/main/java/me/mrletsplay/shareclientcore/document/SharedDocument.java index 2e1016e..d91ebe1 100644 --- a/src/main/java/me/mrletsplay/shareclientcore/document/Document.java +++ b/src/main/java/me/mrletsplay/shareclientcore/document/SharedDocument.java @@ -1,17 +1,34 @@ package me.mrletsplay.shareclientcore.document; -public class Document { +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import me.mrletsplay.shareclientcore.connection.Change; +import me.mrletsplay.shareclientcore.connection.ChangeType; +import me.mrletsplay.shareclientcore.connection.RemoteConnection; +import me.mrletsplay.shareclientcore.connection.RemoteListener; + +public class SharedDocument implements RemoteListener { + + private RemoteConnection connection; private CharBag charBag; + private int document; private int site; private int lamport; + private Set listeners; + + public SharedDocument(RemoteConnection connection) { + this.connection = connection; + connection.addListener(this); - public Document(int site) { this.charBag = new ArrayCharBag(); charBag.add(Char.START_OF_DOCUMENT); charBag.add(Char.END_OF_DOCUMENT); - this.site = site; + this.document = 0; // TODO: implement + this.site = connection.retrieveSiteID(); + this.listeners = new HashSet<>(); } /** @@ -25,13 +42,18 @@ public class Document { Char charBefore = charBag.get(index); Char charAfter = charBag.get(index +1); - for(char c : str.toCharArray()) { + char[] chars = str.toCharArray(); + Change[] changes = new Change[chars.length]; + for(int i = 0; i < chars.length; i++) { Identifier[] newPos = Util.generatePositionBetween(charBefore.position(), charAfter.position(), site); lamport++; - Char ch = new Char(newPos, lamport, c); + Char ch = new Char(newPos, lamport, chars[i]); charBag.add(ch); + changes[i] = new Change(document, ChangeType.ADD, ch); charBefore = ch; } + + connection.send(changes); } /** @@ -41,11 +63,17 @@ public class Document { */ public void localDelete(int index, int n) { if(index < 0 || index + n >= charBag.size() - 1) throw new IllegalArgumentException("Index out of bounds"); + if(n == 0) return; + Change[] changes = new Change[n]; while(n-- > 0) { // TODO: more efficient implementation (e.g. range delete in CharBag) - charBag.remove(charBag.get(index + 1)); + Char toRemove = charBag.get(index + 1); + changes[n] = new Change(document, ChangeType.REMOVE, toRemove); + charBag.remove(toRemove); } + + connection.send(changes); } /** @@ -55,7 +83,10 @@ public class Document { */ public int remoteInsert(Char c) { lamport = Math.max(c.lamport(), lamport) + 1; - return charBag.add(c); + int index = charBag.add(c); + if(index == -1) return -1; + listeners.forEach(l -> l.onInsert(index - 1, c.value())); + return index; } /** @@ -65,7 +96,10 @@ public class Document { */ public int remoteDelete(Char c) { lamport = Math.max(c.lamport(), lamport) + 1; - return charBag.remove(c); + int index = charBag.remove(c); + if(index == -1) return -1; + listeners.forEach(l -> l.onDelete(index - 1)); + return index; } public CharBag getCharBag() { @@ -77,4 +111,23 @@ public class Document { return contents.substring(1, contents.length() - 1); } + public void addListener(DocumentListener listener) { + listeners.add(listener); + } + + public void removeListener(DocumentListener listener) { + listeners.add(listener); + } + + @Override + public void onRemoteChange(Change... changes) { + for(Change c : changes) { + System.out.println("Change: " + c + " | " + Arrays.toString(c.character().position())); + switch(c.type()) { + case ADD -> remoteInsert(c.character()); + case REMOVE -> remoteDelete(c.character()); + } + } + } + } diff --git a/src/test/java/me/mrletsplay/shareclientcore/DocumentTest.java b/src/test/java/me/mrletsplay/shareclientcore/DocumentTest.java index d319b47..6e35723 100644 --- a/src/test/java/me/mrletsplay/shareclientcore/DocumentTest.java +++ b/src/test/java/me/mrletsplay/shareclientcore/DocumentTest.java @@ -5,13 +5,14 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; -import me.mrletsplay.shareclientcore.document.Document; +import me.mrletsplay.shareclientcore.connection.DummyConnection; +import me.mrletsplay.shareclientcore.document.SharedDocument; public class DocumentTest { @Test public void testLocalInsert() { - Document doc = new Document(1); + SharedDocument doc = new SharedDocument(new DummyConnection()); doc.localInsert(0, "Hello"); assertEquals("Hello", doc.getContents()); doc.localInsert(5, " World"); @@ -22,7 +23,7 @@ public class DocumentTest { @Test public void testLocalInsertInvalidIndexFails() { - Document doc = new Document(1); + SharedDocument doc = new SharedDocument(new DummyConnection()); doc.localInsert(0, "Hello"); assertThrows(IllegalArgumentException.class, () -> doc.localInsert(-1, "Test")); assertThrows(IllegalArgumentException.class, () -> doc.localInsert(6, "Test")); @@ -30,7 +31,7 @@ public class DocumentTest { @Test public void testLocalDelete() { - Document doc = new Document(1); + SharedDocument doc = new SharedDocument(new DummyConnection()); doc.localInsert(0, "Hello World!"); doc.localDelete(5, 6); assertEquals("Hello!", doc.getContents()); @@ -38,7 +39,7 @@ public class DocumentTest { @Test public void testLocalDeleteInvalidIndexFails() { - Document doc = new Document(1); + SharedDocument doc = new SharedDocument(new DummyConnection()); doc.localInsert(0, "Hello World!"); assertThrows(IllegalArgumentException.class, () -> doc.localDelete(-1, 10)); assertThrows(IllegalArgumentException.class, () -> doc.localDelete(12, 1));