diff --git a/.gitignore b/.gitignore
index 6ac465db..5a54815d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
target/
/.idea/
+.env
\ No newline at end of file
diff --git a/javafx.iml b/javafx.iml
new file mode 100644
index 00000000..219cba0f
--- /dev/null
+++ b/javafx.iml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index c40f667e..0037c686 100644
--- a/pom.xml
+++ b/pom.xml
@@ -2,6 +2,7 @@
+
4.0.0
com.example
@@ -15,26 +16,32 @@
3.27.6
5.20.0
25
+ 5.3.1
+
+
org.junit.jupiter
junit-jupiter
${junit.jupiter.version}
test
+
org.assertj
assertj-core
${assertj.core.version}
test
+
org.mockito
mockito-junit-jupiter
${mockito.version}
test
+
org.openjfx
javafx-controls
@@ -45,7 +52,21 @@
javafx-fxml
${javafx.version}
+
+
+ org.json
+ json
+ 20240303
+
+
+
+ org.apache.httpcomponents.client5
+ httpclient5
+ ${httpclient.version}
+
+
+
@@ -55,7 +76,7 @@
com.example.HelloFX
-
+
javafx
true
@@ -65,4 +86,5 @@
+
diff --git a/src/main/java/com/example/ChatMessage.java b/src/main/java/com/example/ChatMessage.java
new file mode 100644
index 00000000..6f0aa49a
--- /dev/null
+++ b/src/main/java/com/example/ChatMessage.java
@@ -0,0 +1,34 @@
+package com.example;
+
+public class ChatMessage {
+ private final String id;
+ private final String username;
+ private final String message;
+ private final String timestamp;
+ private final String fileName;
+ private final String fileUrl;
+ private final String mimeType;
+
+ public ChatMessage(String id, String username, String message, String timestamp,
+ String fileName, String fileUrl, String mimeType) {
+ this.id = id;
+ this.username = username;
+ this.message = message;
+ this.timestamp = timestamp;
+ this.fileName = fileName;
+ this.fileUrl = fileUrl;
+ this.mimeType = mimeType;
+ }
+
+ public ChatMessage(String id, String username, String message, String timestamp) {
+ this(id, username, message, timestamp, null, null, null);
+ }
+
+ public String getId() { return id; }
+ public String getUsername() { return username; }
+ public String getMessage() { return message; }
+ public String getTimestamp() { return timestamp; }
+ public String getFileName() { return fileName; }
+ public String getFileUrl() { return fileUrl; }
+ public String getMimeType() { return mimeType; }
+}
diff --git a/src/main/java/com/example/EnvLoader.java b/src/main/java/com/example/EnvLoader.java
new file mode 100644
index 00000000..3d18f64b
--- /dev/null
+++ b/src/main/java/com/example/EnvLoader.java
@@ -0,0 +1,32 @@
+package com.example;
+
+import java.io.BufferedReader;
+import java.io.FileReader;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+public class EnvLoader {
+
+ private static Map env = new HashMap<>();
+
+ static {
+ try (BufferedReader reader = new BufferedReader(new FileReader(".env"))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ line = line.trim();
+ if (line.isEmpty() || line.startsWith("#")) continue;
+ String[] parts = line.split("=", 2);
+ if (parts.length == 2) {
+ env.put(parts[0].trim(), parts[1].trim());
+ }
+ }
+ } catch (IOException e) {
+ System.err.println("Warning: .env file not found or could not be read");
+ }
+ }
+
+ public static String get(String key) {
+ return env.get(key);
+ }
+}
diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java
index fdd160a0..a48eaf29 100644
--- a/src/main/java/com/example/HelloController.java
+++ b/src/main/java/com/example/HelloController.java
@@ -1,22 +1,120 @@
package com.example;
+import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.collections.transformation.FilteredList;
import javafx.fxml.FXML;
-import javafx.scene.control.Label;
+import javafx.fxml.Initializable;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.Hyperlink;
+import javafx.scene.control.ListCell;
+import javafx.scene.control.ListView;
+import javafx.scene.control.TextField;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.text.Text;
+import javafx.scene.text.TextFlow;
+import javafx.stage.FileChooser;
-/**
- * Controller layer: mediates between the view (FXML) and the model.
- */
-public class HelloController {
+import java.io.File;
+import java.net.URL;
+import java.time.format.DateTimeFormatter;
+import java.util.ResourceBundle;
+import java.util.function.Predicate;
+
+public class HelloController implements Initializable {
+
+ @FXML private ListView chatList;
+ @FXML private TextField inputField;
+ @FXML private TextField usernameField;
+ @FXML private CheckBox hideMyMessagesCheck;
private final HelloModel model = new HelloModel();
+ private final ObservableList masterList = FXCollections.observableArrayList();
+ private FilteredList filteredList;
+
+ private String getCurrentUsername() {
+ String u = usernameField.getText();
+ if (u == null) return "Anonymous";
+ u = u.trim();
+ return u.isEmpty() ? "Anonymous" : u;
+ }
+
+ @Override
+ public void initialize(URL url, ResourceBundle rb) {
+ filteredList = new FilteredList<>(masterList, msg -> true);
+ chatList.setItems(filteredList);
+
+ chatList.setCellFactory(list -> new ListCell<>() {
+ @Override
+ protected void updateItem(ChatMessage msg, boolean empty) {
+ super.updateItem(msg, empty);
+ if (empty || msg == null) { setGraphic(null); return; }
+
+ Text user = new Text(msg.getUsername());
+ user.setStyle("-fx-font-weight: bold;");
+ Text time = new Text(" (" + msg.getTimestamp() + ")\n");
+ time.setStyle("-fx-fill: gray; -fx-font-size: 12px;");
+
+ if (msg.getFileName() != null && msg.getFileUrl() != null) {
+ if (msg.getMimeType() != null && msg.getMimeType().startsWith("image/")) {
+ try {
+ ImageView imageView = new ImageView(new Image(msg.getFileUrl(), true));
+ imageView.setFitWidth(200);
+ imageView.setPreserveRatio(true);
+ Text messageText = new Text(msg.getMessage() + "\n");
+ messageText.setStyle("-fx-font-size: 14px;");
+ setGraphic(new TextFlow(user, time, messageText, imageView));
+ } catch (Exception e) { e.printStackTrace(); }
+ } else {
+ Hyperlink link = new Hyperlink(msg.getFileName());
+ final String fileUrl = msg.getFileUrl();
+ link.setOnAction(ev -> HelloFX.hostServices().showDocument(fileUrl));
+ Text messageText = new Text(msg.getMessage() + "\n");
+ messageText.setStyle("-fx-font-size: 14px;");
+ setGraphic(new TextFlow(user, time, messageText, link));
+ }
+ } else {
+ Text text = new Text(msg.getMessage());
+ text.setStyle("-fx-font-size: 14px;");
+ setGraphic(new TextFlow(user, time, text));
+ }
+ }
+ });
+
+ hideMyMessagesCheck.selectedProperty().addListener((obs, oldVal, newVal) -> updateFilterPredicate());
+ usernameField.textProperty().addListener((obs, oldVal, newVal) -> updateFilterPredicate());
+
+ model.loadHistory(msg -> Platform.runLater(() -> masterList.add(msg)));
+ model.listenForMessages(msg -> Platform.runLater(() -> masterList.add(msg)));
+ }
+
+ private void updateFilterPredicate() {
+ final String current = getCurrentUsername();
+ final boolean hideMine = hideMyMessagesCheck.isSelected();
+ Predicate pred = msg -> !hideMine || !current.equals(msg.getUsername());
+ filteredList.setPredicate(pred);
+ }
@FXML
- private Label messageLabel;
+ private void onSend() {
+ String user = getCurrentUsername();
+ String msg = inputField.getText().trim();
+ if (msg.isEmpty()) return;
+ inputField.clear();
+
+ new Thread(() -> {
+ try { model.sendMessage(user, msg); }
+ catch (Exception e) { e.printStackTrace(); }
+ }).start();
+ }
@FXML
- private void initialize() {
- if (messageLabel != null) {
- messageLabel.setText(model.getGreeting());
- }
+ private void onAttachFile() {
+ FileChooser fileChooser = new FileChooser();
+ fileChooser.setTitle("Select a file to send");
+ File file = fileChooser.showOpenDialog(chatList.getScene().getWindow());
+ if (file != null) new Thread(() -> model.sendFile(getCurrentUsername(), file)).start();
}
}
diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java
index 96bdc5ca..316c72ac 100644
--- a/src/main/java/com/example/HelloFX.java
+++ b/src/main/java/com/example/HelloFX.java
@@ -1,25 +1,32 @@
package com.example;
import javafx.application.Application;
+import javafx.application.HostServices;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
public class HelloFX extends Application {
+ public static HostServices hostServices;
+
+ public static HostServices hostServices() {
+ return hostServices;
+ }
@Override
public void start(Stage stage) throws Exception {
- FXMLLoader fxmlLoader = new FXMLLoader(HelloFX.class.getResource("hello-view.fxml"));
- Parent root = fxmlLoader.load();
- Scene scene = new Scene(root, 640, 480);
- stage.setTitle("Hello MVC");
+ hostServices = getHostServices();
+ FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/example/hello-view.fxml"));
+ Parent root = loader.load();
+ Scene scene = new Scene(root, 720, 520);
+ scene.getStylesheets().add(getClass().getResource("/css/style.css").toExternalForm());
+ stage.setTitle("JavaFX NTFY Chat — mats_notiser");
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
- launch();
+ launch(args);
}
-
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/example/HelloModel.java b/src/main/java/com/example/HelloModel.java
index 385cfd10..67fa465d 100644
--- a/src/main/java/com/example/HelloModel.java
+++ b/src/main/java/com/example/HelloModel.java
@@ -1,15 +1,262 @@
package com.example;
-/**
- * Model layer: encapsulates application data and business logic.
- */
+import org.json.JSONObject;
+
+import java.io.*;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.function.Consumer;
+
public class HelloModel {
- /**
- * Returns a greeting based on the current Java and JavaFX versions.
- */
- public String getGreeting() {
- String javaVersion = System.getProperty("java.version");
- String javafxVersion = System.getProperty("javafx.version");
- return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + ".";
+ private final String TOPIC_URL;
+ private final Set seenIds = Collections.synchronizedSet(new HashSet<>());
+
+ public HelloModel() {
+ TOPIC_URL = EnvLoader.get("NTFY_URL");
+ if (TOPIC_URL == null || TOPIC_URL.isBlank()) {
+ throw new IllegalStateException("NTFY_URL not found in .env file");
+ }
+ }
+
+ public HelloModel(String topicUrl) {
+ if (topicUrl == null || topicUrl.isBlank()) {
+ throw new IllegalStateException("NTFY_URL not found in .env file");
+ }
+ this.TOPIC_URL = topicUrl;
+ }
+
+ public void sendMessage(String username, String message) throws IOException {
+ JSONObject json = new JSONObject();
+ json.put("username", username == null || username.isBlank() ? "Anonymous" : username);
+ json.put("message", message == null ? "" : message);
+ json.put("time", Instant.now().getEpochSecond());
+ sendJsonToNtfy(json);
+ }
+
+ protected void sendFile(String username, File file) {
+ if (file == null || !file.exists()) return;
+ final String safeUsername = (username == null || username.isBlank()) ? "Anonymous" : username;
+
+ new Thread(() -> {
+ HttpURLConnection conn = null;
+ try {
+ URL url = new URL(TOPIC_URL);
+ conn = (HttpURLConnection) url.openConnection();
+ conn.setDoOutput(true);
+ conn.setRequestMethod("PUT");
+
+ String mimeType = Files.probeContentType(file.toPath());
+ if (mimeType == null) mimeType = "application/octet-stream";
+
+ conn.setRequestProperty("Filename", file.getName());
+ conn.setRequestProperty("Content-Type", mimeType);
+ conn.setRequestProperty("X-Hide", "true");
+ conn.setFixedLengthStreamingMode(file.length());
+ conn.connect();
+
+ try (OutputStream os = conn.getOutputStream()) {
+ Files.copy(file.toPath(), os);
+ os.flush();
+ }
+
+ int rc = conn.getResponseCode();
+ InputStream respStream = rc >= 400 ? conn.getErrorStream() : conn.getInputStream();
+ String responseJson = "";
+ if (respStream != null) responseJson = new String(respStream.readAllBytes(), StandardCharsets.UTF_8);
+ System.out.println("PUT upload response code: " + rc);
+ if (!responseJson.isBlank()) System.out.println("PUT upload response body: " + responseJson);
+
+ String fileUrl = null;
+ try {
+ if (!responseJson.isBlank()) {
+ JSONObject resp = new JSONObject(responseJson);
+ if (resp.has("attachment")) {
+ JSONObject attach = resp.getJSONObject("attachment");
+ if (attach.has("url")) fileUrl = attach.getString("url");
+ } else if (resp.has("url")) {
+ fileUrl = resp.getString("url");
+ }
+ }
+ } catch (Exception ex) {
+ System.err.println("Failed to parse upload response JSON: " + ex.getMessage());
+ }
+
+ if (fileUrl == null && rc >= 200 && rc < 300) {
+ if (!TOPIC_URL.endsWith("/")) fileUrl = TOPIC_URL + "/" + file.getName();
+ else fileUrl = TOPIC_URL + file.getName();
+ }
+
+ JSONObject msg = new JSONObject();
+ msg.put("username", safeUsername);
+ msg.put("message", "Sent file: " + file.getName());
+ msg.put("time", Instant.now().getEpochSecond());
+ msg.put("fileName", file.getName());
+ msg.put("fileUrl", fileUrl);
+ msg.put("mimeType", mimeType);
+
+ sendJsonToNtfy(msg);
+
+ } catch (Exception e) {
+ e.printStackTrace();
+ } finally {
+ if (conn != null) conn.disconnect();
+ }
+ }).start();
+ }
+
+ protected void sendJsonToNtfy(JSONObject json) throws IOException {
+ byte[] out = json.toString().getBytes(StandardCharsets.UTF_8);
+
+ URL url = new URL(TOPIC_URL);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setDoOutput(true);
+ conn.setRequestMethod("POST");
+ conn.setRequestProperty("Content-Type", "application/json");
+ conn.setFixedLengthStreamingMode(out.length);
+ conn.connect();
+
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write(out);
+ os.flush();
+ }
+
+ int rc = conn.getResponseCode();
+ System.out.println("sendJsonToNtfy() -> response: " + rc);
+ if (rc < 200 || rc >= 300) {
+ InputStream err = conn.getErrorStream();
+ if (err != null) {
+ String body = new String(err.readAllBytes(), StandardCharsets.UTF_8);
+ System.err.println("ntfy error body: " + body);
+ }
+ }
+
+ try (InputStream is = conn.getInputStream()) { if (is != null) is.readAllBytes(); } catch (IOException ignored) {}
+ conn.disconnect();
}
+
+ public void loadHistory(Consumer callback) {
+ new Thread(() -> {
+ HttpURLConnection conn = null;
+ try {
+ URL url = new URL(TOPIC_URL + "/json?since=all");
+ conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("GET");
+ conn.connect();
+
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.isBlank()) continue;
+
+ try {
+ JSONObject envelope = new JSONObject(line);
+ String id = envelope.optString("id", null);
+ if (id != null && !seenIds.add(id)) continue;
+
+ String rawMsg = envelope.optString("message", "").trim();
+ if (!rawMsg.startsWith("{") || !rawMsg.endsWith("}")) continue;
+
+ ChatMessage msg = parseEnvelopeToChatMessage(envelope);
+ if (msg != null) callback.accept(msg);
+
+ } catch (Exception ex) {
+ ex.printStackTrace();
+ }
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ } finally {
+ if (conn != null) conn.disconnect();
+ }
+ }).start();
+ }
+
+
+ public void listenForMessages(Consumer callback) {
+ new Thread(() -> {
+ HttpURLConnection conn = null;
+ try {
+ URL url = new URL(TOPIC_URL + "/json");
+ conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("GET");
+ conn.setRequestProperty("Accept", "application/json");
+ conn.connect();
+
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.isBlank()) continue;
+
+ try {
+ JSONObject envelope = new JSONObject(line);
+ String id = envelope.optString("id", null);
+ if (id != null && !seenIds.add(id)) continue;
+
+ String rawMsg = envelope.optString("message", "").trim();
+ if (!rawMsg.startsWith("{") || !rawMsg.endsWith("}")) continue;
+
+ ChatMessage msg = parseEnvelopeToChatMessage(envelope);
+ if (msg != null) callback.accept(msg);
+
+ } catch (Exception ex) {
+ ex.printStackTrace();
+ }
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ } finally {
+ if (conn != null) conn.disconnect();
+ }
+ }, "ntfy-listener-thread").start();
+ }
+
+
+
+ protected ChatMessage parseEnvelopeToChatMessage(JSONObject envelope) {
+ String rawMsg = envelope.optString("message", null);
+ if (rawMsg == null) return null;
+ if (!rawMsg.startsWith("{") || !rawMsg.endsWith("}")) return null;
+ try {
+ String id = envelope.optString("id", null);
+ long envelopeTime = envelope.optLong("time", Instant.now().getEpochSecond());
+ String timestamp = Instant.ofEpochSecond(envelopeTime)
+ .atZone(ZoneId.systemDefault())
+ .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
+
+ String username = envelope.optString("username", "unknown");
+
+ if (envelope.has("message")) {
+ rawMsg = envelope.getString("message");
+ JSONObject inner = null;
+ try { inner = new JSONObject(rawMsg); } catch (Exception ignored) {}
+
+ if (inner != null && inner.length() > 0) {
+ username = inner.optString("username", username);
+ String messageText = inner.optString("message", "");
+ String fileName = inner.optString("fileName", null);
+ String fileUrl = inner.optString("fileUrl", null);
+ String mimeType = inner.optString("mimeType", null);
+
+ if (fileName != null || fileUrl != null)
+ return new ChatMessage(id, username, messageText, timestamp, fileName, fileUrl, mimeType);
+ else
+ return new ChatMessage(id, username, messageText, timestamp);
+ } else {
+ return new ChatMessage(id, username, rawMsg, timestamp);
+ }
+ }
+ } catch (Exception e) { e.printStackTrace(); }
+ return null;
+ }
+
}
diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
index 71574a27..bef7b565 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -1,6 +1,8 @@
module hellofx {
requires javafx.controls;
requires javafx.fxml;
+ requires org.json;
+ requires java.net.http;
opens com.example to javafx.fxml;
exports com.example;
diff --git a/src/main/resources/background.png b/src/main/resources/background.png
new file mode 100644
index 00000000..65e8e29a
Binary files /dev/null and b/src/main/resources/background.png differ
diff --git a/src/main/resources/com/example/hello-view.fxml b/src/main/resources/com/example/hello-view.fxml
index 20a7dc82..e2edd693 100644
--- a/src/main/resources/com/example/hello-view.fxml
+++ b/src/main/resources/com/example/hello-view.fxml
@@ -1,9 +1,53 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/css/style.css b/src/main/resources/css/style.css
new file mode 100644
index 00000000..39772747
--- /dev/null
+++ b/src/main/resources/css/style.css
@@ -0,0 +1,23 @@
+.root {
+ -fx-font-family: "Segoe UI", Roboto, Arial, sans-serif;
+ -fx-font-size: 13px;
+}
+
+.list-cell {
+ -fx-padding: 6px 8px;
+}
+
+.button {
+ -fx-background-radius: 6px;
+ -fx-padding: 6 12 6 12;
+}
+
+.text-field {
+ -fx-background-radius: 6px;
+ -fx-padding: 6px;
+ -fx-border-color: derive(-fx-control-inner-background, -10%);
+}
+
+.progress-bar {
+ -fx-accent: -fx-focus-color;
+}
diff --git a/src/main/resources/lantern.png b/src/main/resources/lantern.png
new file mode 100644
index 00000000..ca3ac42c
Binary files /dev/null and b/src/main/resources/lantern.png differ
diff --git a/src/main/resources/lantern1.png b/src/main/resources/lantern1.png
new file mode 100644
index 00000000..324f88c5
Binary files /dev/null and b/src/main/resources/lantern1.png differ
diff --git a/src/main/resources/lantern2.png b/src/main/resources/lantern2.png
new file mode 100644
index 00000000..24670373
Binary files /dev/null and b/src/main/resources/lantern2.png differ
diff --git a/src/main/resources/lantern3.png b/src/main/resources/lantern3.png
new file mode 100644
index 00000000..faa90dd4
Binary files /dev/null and b/src/main/resources/lantern3.png differ
diff --git a/src/main/resources/lantern4.png b/src/main/resources/lantern4.png
new file mode 100644
index 00000000..5e9c8445
Binary files /dev/null and b/src/main/resources/lantern4.png differ
diff --git a/src/test/java/com/example/HelloModelTest.java b/src/test/java/com/example/HelloModelTest.java
new file mode 100644
index 00000000..2be0c7ed
--- /dev/null
+++ b/src/test/java/com/example/HelloModelTest.java
@@ -0,0 +1,110 @@
+package com.example;
+
+import org.json.JSONObject;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.time.Instant;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class HelloModelTest {
+
+ private HelloModel model;
+
+ @BeforeEach
+ void setUp() {
+ model = new HelloModel("https://dummy-url-for-tests") {
+ @Override
+ protected void sendJsonToNtfy(JSONObject json) {
+ System.out.println("Mock sendJsonToNtfy called: " + json);
+ }
+
+ @Override
+ protected void sendFile(String username, File file) {
+ if (file != null) {
+ System.out.println("Mock sendFileInternal called for: " + file.getName());
+ } else {
+ System.out.println("Mock sendFileInternal called with null file");
+ }
+ }
+
+ @Override
+ protected ChatMessage parseEnvelopeToChatMessage(JSONObject envelope) {
+ return super.parseEnvelopeToChatMessage(envelope);
+ }
+ };
+ }
+
+ @Test
+ void testSendMessageWithValidUsernameAndText() {
+ assertDoesNotThrow(() -> model.sendMessage("Alice", "Hello World"));
+ }
+
+ @Test
+ void testSendMessageWithNullUsername() {
+ assertDoesNotThrow(() -> model.sendMessage(null, "Hello World"));
+ }
+
+ @Test
+ void testSendMessageWithNullMessage() {
+ assertDoesNotThrow(() -> model.sendMessage("Alice", null));
+ }
+
+ @Test
+ void testSendFileWithValidFile() throws Exception {
+ File tempFile = File.createTempFile("testfile", ".txt");
+ tempFile.deleteOnExit();
+
+ assertDoesNotThrow(() -> model.sendFile("Bob", tempFile));
+ }
+
+ @Test
+ void testSendFileWithNullFile() {
+ assertDoesNotThrow(() -> model.sendFile("Bob", null));
+ }
+
+ @Test
+ void testJsonParsingCreatesChatMessage() {
+ JSONObject envelope = new JSONObject();
+ envelope.put("id", "123");
+ envelope.put("username", "Alice");
+ envelope.put("message", "{\"username\":\"Alice\",\"message\":\"Hi there\",\"time\":" + Instant.now().getEpochSecond() + "}");
+ envelope.put("time", Instant.now().getEpochSecond());
+
+ ChatMessage msg = model.parseEnvelopeToChatMessage(envelope);
+ assertNotNull(msg);
+ assertEquals("Alice", msg.getUsername());
+ assertEquals("Hi there", msg.getMessage());
+ }
+
+ @Test
+ void testFilterOutNonJsonMessages() {
+ JSONObject envelope = new JSONObject();
+ envelope.put("id", "124");
+ envelope.put("username", "Bob");
+ envelope.put("message", "Just plain text");
+ envelope.put("time", Instant.now().getEpochSecond());
+
+ ChatMessage msg = model.parseEnvelopeToChatMessage(envelope);
+ assertNull(msg);
+ }
+
+
+ @Test
+ void testLoadHistoryCallbackCalled() {
+ AtomicBoolean called = new AtomicBoolean(false);
+ model.loadHistory(msg -> called.set(true));
+ assertDoesNotThrow(() -> model.loadHistory(msg -> {}));
+ }
+
+ @Test
+ void testListenForMessagesCallbackCalled() {
+ AtomicReference ref = new AtomicReference<>();
+ model.listenForMessages(ref::set);
+ assertDoesNotThrow(() -> model.listenForMessages(msg -> {}));
+ }
+}