diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..1ba9c1af --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +NTFY_BASE_URL=https://ntfy.sh +NTFY_TOPIC= //Add your topic here \ No newline at end of file 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/README.md b/README.md index 5fdc622f..6df13fda 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,20 @@ A JavaFX-based chat client using [ntfy](https://docs.ntfy.sh/) for backend messa - Unit tests for `Model` class - (Advanced) Send files via "Attach local file" option -## 🚀 Run Instructions -1. Set `JAVA_HOME` to JDK 25 + +## Requirements + +- **Java** + - **Version**: `25` + +- **Maven Compiler Plugin** + - **Version**: `3.11.0` + - **Configuration**: + - **Release**: `25` + +## Usage +1. Set `JAVA_HOME` to JDK 25. +2. Create a **.env** file with the required variables. You can also clone and fill **.env.example** and rename it to `.env`. 2. Start with: ```bash ./mvnw clean javafx:run diff --git a/pom.xml b/pom.xml index c40f667e..177d3c50 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,21 @@ 25 + + org.slf4j + slf4j-api + 2.0.9 + + + org.slf4j + slf4j-simple + 2.0.9 + + + com.fasterxml.jackson.core + jackson-databind + 2.17.2 + org.junit.jupiter junit-jupiter @@ -45,6 +60,18 @@ javafx-fxml ${javafx.version} + + org.testfx + testfx-junit5 + 4.0.17 + test + + + org.openjfx + javafx-swing + ${javafx.version} + test + @@ -63,6 +90,14 @@ true + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 25 + + diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java index fdd160a0..7474c4ff 100644 --- a/src/main/java/com/example/HelloController.java +++ b/src/main/java/com/example/HelloController.java @@ -1,22 +1,230 @@ package com.example; +import com.example.client.ChatNetworkClient; +import com.example.domain.ChatModel; +import com.example.domain.NtfyEventResponse; +import com.example.domain.NtfyMessage; +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; +import javafx.event.ActionEvent; import javafx.fxml.FXML; +import javafx.scene.control.*; import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -/** - * Controller layer: mediates between the view (FXML) and the model. - */ -public class HelloController { +import java.awt.*; +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Instant; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; - private final HelloModel model = new HelloModel(); +public class HelloController { + private static final Logger log = LoggerFactory.getLogger(HelloController.class); + private ChatNetworkClient client; + private String baseUrl; + private String topic; + private File selectedFile = null; @FXML private Label messageLabel; @FXML - private void initialize() { - if (messageLabel != null) { - messageLabel.setText(model.getGreeting()); + private ListView messagesList; + + @FXML + private TextField messageInput; + + @FXML + private TextField titleInput; + + @FXML + private TextField tagsInput; + + public void setClient(ChatNetworkClient client, String baseUrl, String topic) { + this.client = client; + this.baseUrl = baseUrl; + this.topic = topic; + } + + public void setModel(ChatModel model) { + messagesList.setItems(model.getMessages()); + messagesList.setCellFactory(list -> new MessageCell()); + } + + private static String formatTime(long epochSeconds) { + Instant instant = Instant.ofEpochSecond(epochSeconds); + LocalTime time = LocalTime.ofInstant(instant, ZoneId.systemDefault()); + return time.toString(); + } + + private void showStatus(String text) { + messageLabel.setText(text); + Timeline t = new Timeline(new KeyFrame(javafx.util.Duration.seconds(3), + ev -> messageLabel.setText(""))); + t.setCycleCount(1); + t.play(); + } + + + @FXML + private void onPickAttachment() { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Select attachment"); + File file = chooser.showOpenDialog(messageInput.getScene().getWindow()); + + if (file != null) { + selectedFile = file; + messageLabel.setText("Attachment selected: " + file.getName()); + } + } + + @FXML + private void onSend() { + String txt = messageInput.getText(); + + if ((txt == null || txt.isBlank()) && selectedFile == null) { + showStatus("Nothing to send"); + return; + } + + String title = titleInput.getText(); + if (title != null && title.isBlank()) title = null; + + String tagsRaw = tagsInput.getText(); + List tags = null; + + if (tagsRaw != null && !tagsRaw.isBlank()) { + tags = java.util.Arrays.stream(tagsRaw.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } + + NtfyMessage msg = new NtfyMessage.Builder() + .id(UUID.randomUUID().toString()) + .time(System.currentTimeMillis()) + .event("message") + .topic(topic) + .message(txt) + .title(title) + .tags(tags) + .attach(null) + .filename(null) + .build(); + + try { + client.send(baseUrl, msg, selectedFile); + showStatus(selectedFile == null ? "Message sent" : "Attachment sent"); + } catch (InterruptedException | IOException e) { + showStatus("Error sending: " + e.getMessage()); + } + + messageInput.clear(); + titleInput.clear(); + tagsInput.clear(); + selectedFile = null; + } + + + private static final class MessageCell extends ListCell { + @Override + protected void updateItem(NtfyEventResponse msg, boolean empty) { + super.updateItem(msg, empty); + + if (empty || msg == null) { + setText(null); + setGraphic(null); + return; + } + + setText(null); + + VBox container = new VBox(); + container.setSpacing(6); + container.getStyleClass().add("message-bubble"); + + container.setStyle("-fx-alignment: CENTER_LEFT;"); + if (msg.title() != null) { + Label titleLabel = new Label(msg.title()); + titleLabel.getStyleClass().add("message-title"); + container.getChildren().add(titleLabel); + } + + if (msg.attachment() != null) { + NtfyEventResponse.Attachment att = msg.attachment(); + + if (att.type() != null && att.type().startsWith("image")) { + Image image = new Image(att.url(), 300, 0, true, true); + ImageView imageView = new ImageView(image); + container.getChildren().add(imageView); + } else { + Label fileLabel = getFileLabel(att); + container.getChildren().add(fileLabel); + } + } + + if (msg.message() != null && !msg.message().isBlank()) { + Label messageLabel = new Label(msg.message()); + messageLabel.setWrapText(true); + messageLabel.getStyleClass().add("message-text"); + container.getChildren().add(messageLabel); + } + + if (msg.tags() != null && !msg.tags().isEmpty()) { + Label tagsLabel = new Label(String.join(", ", msg.tags())); + tagsLabel.getStyleClass().add("message-tags"); + container.getChildren().add(tagsLabel); + } + + if (msg.time() != null) { + Label timeLabel = new Label(formatTime(msg.time())); + timeLabel.getStyleClass().add("message-time"); + container.getChildren().add(timeLabel); + } + + setGraphic(container); + } + + // Helper method to allow user to open file + private static Label getFileLabel(NtfyEventResponse.Attachment att) { + Label fileLabel = new Label("Open file: " + (att.name() != null ? att.name() : att.url())); + fileLabel.setStyle("-fx-text-fill: #2c75ff; -fx-underline: true;"); + fileLabel.setOnMouseClicked(ev -> { + try { + String url = att.url(); + + // method that works on linux as Desktop is not always supported and crashes application + if (System.getProperty("os.name").toLowerCase().contains("linux")) { + try { + new ProcessBuilder("xdg-open", url).start(); + return; + }catch (IOException e) { + log.error("Error opening file: {}", url, e); + } + } + + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().browse(new URI(url)); + } + + } catch (IOException | URISyntaxException ex) { + log.error("Failed to open attachment: {}", ex.getMessage()); + log.error(Arrays.toString(ex.getStackTrace())); + } + }); + return fileLabel; } } } diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java index 96bdc5ca..6d9a8345 100644 --- a/src/main/java/com/example/HelloFX.java +++ b/src/main/java/com/example/HelloFX.java @@ -1,25 +1,59 @@ package com.example; +import com.example.client.ChatNetworkClient; +import com.example.client.NtfyHttpClient; +import com.example.domain.ChatModel; +import com.example.domain.NtfyMessage; import javafx.application.Application; import javafx.fxml.FXMLLoader; import javafx.scene.Parent; import javafx.scene.Scene; import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Objects; +import java.util.Properties; + +import static com.example.utils.EnvLoader.loadEnv; public class HelloFX extends Application { + private static final Logger log = LoggerFactory.getLogger("MAIN"); + static final ChatModel model = new ChatModel(); @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"); + Properties env = loadEnv(); + String baseUrl = env.getProperty("NTFY_BASE_URL", "https://ntfy.sh"); + String topic = env.getProperty("NTFY_TOPIC"); + + if (topic == null || topic.isBlank()) { + throw new IllegalStateException("NTFY_TOPIC is not set"); + } + + FXMLLoader loader = new FXMLLoader(HelloFX.class.getResource("hello-view.fxml")); + Parent root = loader.load(); + + HelloController controller = loader.getController(); + controller.setModel(model); + + ChatNetworkClient client = new NtfyHttpClient(model); + controller.setClient(client, baseUrl, topic); + + client.subscribe(baseUrl, topic); + + Scene scene = new Scene(root); + scene.getStylesheets().add( + Objects.requireNonNull(HelloFX.class.getResource("styles.css")).toExternalForm() + ); + 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/client/ChatNetworkClient.java b/src/main/java/com/example/client/ChatNetworkClient.java new file mode 100644 index 00000000..815418e4 --- /dev/null +++ b/src/main/java/com/example/client/ChatNetworkClient.java @@ -0,0 +1,17 @@ +package com.example.client; + + +import com.example.domain.NtfyMessage; + +import java.io.File; +import java.io.IOException; + +public interface ChatNetworkClient { + Subscription subscribe(String baseUrl, String topic); + void send(String baseUrl, NtfyMessage message, File file) throws IOException, InterruptedException; + interface Subscription extends AutoCloseable { + @Override + void close(); + boolean isOpen(); + } +} diff --git a/src/main/java/com/example/client/HttpClientProvider.java b/src/main/java/com/example/client/HttpClientProvider.java new file mode 100644 index 00000000..a0e86184 --- /dev/null +++ b/src/main/java/com/example/client/HttpClientProvider.java @@ -0,0 +1,15 @@ +package com.example.client; + +import java.net.http.HttpClient; + +public final class HttpClientProvider { + + private static final HttpClient INSTANCE = HttpClient.newHttpClient(); + + private HttpClientProvider() { + } + + public static HttpClient get() { + return INSTANCE; + } +} diff --git a/src/main/java/com/example/client/NtfyHttpClient.java b/src/main/java/com/example/client/NtfyHttpClient.java new file mode 100644 index 00000000..e0cb8f5f --- /dev/null +++ b/src/main/java/com/example/client/NtfyHttpClient.java @@ -0,0 +1,131 @@ +package com.example.client; + +import com.example.domain.ChatModel; +import com.example.domain.NtfyEventResponse; +import com.example.domain.NtfyMessage; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; + +public record NtfyHttpClient(ChatModel model) implements ChatNetworkClient { + + private static final ObjectMapper mapper = new ObjectMapper(); + private static final Logger log = LoggerFactory.getLogger("NtfyClient"); + + @Override + public Subscription subscribe(String baseUrl, String topic) { + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(baseUrl).resolve(topic + "/json")) + .header("accept", "application/json") + .GET() + .build(); + + CompletableFuture>> future = + HttpClientProvider.get().sendAsync(req, HttpResponse.BodyHandlers.ofLines()); + + AtomicBoolean open = new AtomicBoolean(true); + + future.thenAccept(response -> { + response.body().forEach(line -> { + + log.debug("Raw event received: {}", line); + + if (!open.get()) return; + + try { + NtfyEventResponse msg = mapper.readValue(line, NtfyEventResponse.class); + if (msg.event().equals("message")) { + model.addMessage(msg); + log.info("Message added: {}", msg); + } + } catch (JsonProcessingException e) { + log.error("Error parsing event: {}", line, e); + } + }); + }).exceptionally(ex -> { + log.error("Error while subscribing to topic {}", topic, ex); + open.set(false); + return null; + }); + log.info("Subscribing to topic: {}", topic); + + return new Subscription() { + @Override + public void close() { + open.set(false); + future.cancel(true); + } + + @Override + public boolean isOpen() { + return open.get(); + } + }; + } + + @Override + public void send(String baseUrl, NtfyMessage msg, File attachment) throws IOException, InterruptedException { + + if (attachment != null) { + sendWithAttachment(baseUrl, msg, attachment); + return; + } + + sendJsonOnly(baseUrl, msg); + } + + private void sendJsonOnly(String baseUrl, NtfyMessage msg) throws IOException, InterruptedException { + String json = mapper.writeValueAsString(msg); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(json)) + .build(); + + var response = HttpClientProvider.get().send(request, HttpResponse.BodyHandlers.ofString()); + int statusCode = response.statusCode(); + + if (statusCode >= 200 && statusCode < 300) { + log.debug("Sent message payload: {}", json); + log.info("Message sent"); + } + log.error("Failed to send message payload: {}", json); + throw new IOException("Failed to send message payload: " + statusCode); + } + + private void sendWithAttachment(String baseUrl, NtfyMessage msg, File file) + throws IOException, InterruptedException { + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl).resolve((msg.topic()))) + .header("Filename", file.getName()) + .PUT(HttpRequest.BodyPublishers.ofFile(file.toPath())) + .build(); + + + var response = HttpClientProvider.get().send(request, HttpResponse.BodyHandlers.ofString()); + + int statusCode = response.statusCode(); + if (200 <= statusCode && statusCode < 300) { + log.debug("Attachment sent: {}", statusCode); + log.info("status: {}", statusCode); + } + log.error("Failed to send attachment: {}", statusCode); + throw new IOException("Failed to send attachment: " + statusCode); + + } + + +} diff --git a/src/main/java/com/example/domain/ChatModel.java b/src/main/java/com/example/domain/ChatModel.java new file mode 100644 index 00000000..3524a777 --- /dev/null +++ b/src/main/java/com/example/domain/ChatModel.java @@ -0,0 +1,36 @@ +package com.example.domain; + +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; + + +public class ChatModel { + private final ObservableList messages = FXCollections.observableArrayList(); + + + public void addMessage(NtfyEventResponse msg) { + runOnFx(() -> messages.add(msg)); + } + + public ObservableList getMessages() { + return messages; + } + + private static void runOnFx(Runnable task) { + try { + if (Platform.isFxApplicationThread()) { + task.run(); + } else if (!Platform.isImplicitExit()) { + Platform.runLater(task); + } else { + // execute test case immediately + task.run(); + } + } catch (IllegalStateException notInitialized) { + task.run(); + } + } + + +} diff --git a/src/main/java/com/example/domain/NtfyEventResponse.java b/src/main/java/com/example/domain/NtfyEventResponse.java new file mode 100644 index 00000000..603f2a8d --- /dev/null +++ b/src/main/java/com/example/domain/NtfyEventResponse.java @@ -0,0 +1,26 @@ +package com.example.domain; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record NtfyEventResponse( + String id, + Long time, + String event, + String topic, + String message, + String title, + List tags, + Attachment attachment +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record Attachment( + String name, + String type, + Long size, + Long expires, + String url + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/example/domain/NtfyMessage.java b/src/main/java/com/example/domain/NtfyMessage.java new file mode 100644 index 00000000..d838d5fc --- /dev/null +++ b/src/main/java/com/example/domain/NtfyMessage.java @@ -0,0 +1,72 @@ +package com.example.domain; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record NtfyMessage( + String id, + Long time, + String event, + String topic, + String message, + String title, + List tags, + String attach, + String filename +) { + public static class Builder { + private String id; + private Long time; + private String event; + private String topic; + private String message; + private String title; + private List tags; + private String attach; + private String filename; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder time(long time) { + this.time = time; + return this; + } + + public Builder event(String event) { + this.event = event; + return this; + } + + public Builder topic(String topic) { + this.topic = topic; + return this; + } + + public Builder message(String message) { + this.message = message; + return this; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder tags(List tags) { + this.tags = tags; + return this; + } + + public Builder attach(String attach) { this.attach = attach; return this; } + public Builder filename(String filename) { this.filename = filename; return this; } + + public NtfyMessage build() { + return new NtfyMessage(id, time, event, topic, message, title, tags, attach, filename); + } + } +} diff --git a/src/main/java/com/example/utils/EnvLoader.java b/src/main/java/com/example/utils/EnvLoader.java new file mode 100644 index 00000000..73ad87be --- /dev/null +++ b/src/main/java/com/example/utils/EnvLoader.java @@ -0,0 +1,27 @@ +package com.example.utils; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Properties; + +public class EnvLoader { + private static final Logger log = LoggerFactory.getLogger(EnvLoader.class); + + public static Properties loadEnv() { + Properties props = new Properties(); + + try (FileInputStream fis = new FileInputStream(".env")) { + props.load(fis); + } catch (FileNotFoundException e) { + log.error("Could not load .env file", e); + } catch (IOException e) { + log.error("Failed to load env file: ", e); + } + + return props; + } +} \ No newline at end of file diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 71574a27..0c455103 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,7 +1,16 @@ module hellofx { requires javafx.controls; requires javafx.fxml; + requires java.net.http; + requires com.fasterxml.jackson.annotation; + requires com.fasterxml.jackson.databind; + requires java.logging; + requires org.slf4j; + requires java.desktop; opens com.example to javafx.fxml; + opens com.example.domain to com.fasterxml.jackson.databind; exports com.example; + exports com.example.domain; + exports com.example.client; } \ No newline at end of file diff --git a/src/main/resources/com/example/hello-view.fxml b/src/main/resources/com/example/hello-view.fxml index 20a7dc82..b080e6d3 100644 --- a/src/main/resources/com/example/hello-view.fxml +++ b/src/main/resources/com/example/hello-view.fxml @@ -1,9 +1,43 @@ - - - - - - - + + + + + + + +